ets-to-homeassistant 0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +4 -0
- data/CHANGELOG.md +12 -0
- data/README.md +258 -0
- data/bin/ets_to_hass +111 -0
- data/lib/ets_to_hass/generator.rb +364 -0
- data/lib/ets_to_hass/info.rb +9 -0
- data/lib/ets_to_hass/specific/generic.rb +28 -0
- data/lib/ets_to_hass/specific/laurent.rb +73 -0
- data/lib/ets_to_hass/specific/specific2.rb +55 -0
- data/lib/ets_to_hass/string_colors.rb +56 -0
- data.tar.gz.sig +0 -0
- metadata +253 -0
- metadata.gz.sig +2 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 7bf61e1b960298191c6f9465cf0817b1343b5f8732a97db30b0abaacde94ccef
|
4
|
+
data.tar.gz: 8ef2e06491f0b08a9fd7511b677aa545af145c2a35ef7ad1e95d8691b93c25fc
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: b97e9426c6f44520e9e709bb748d7ddbd092d249c8b8611a9d71cbe9fe80739683e736721ae8cab4a8bc918cae1b5f272bd4dcc2c0c379454a58f7da22d0353b
|
7
|
+
data.tar.gz: efb10210a466e020a8bc59172e0affa36efe19695adab19430545d83f832d6459e3cfe2c54dbb74cf02ac758dc947ed8ac3cf4beb5a14ce526ef33acc3925fc7
|
checksums.yaml.gz.sig
ADDED
data/CHANGELOG.md
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
# Changes (Release notes)
|
2
|
+
|
3
|
+
* 0.1
|
4
|
+
|
5
|
+
* New Features:
|
6
|
+
* initial version number
|
7
|
+
* option `--sort-by-name` to sort by object names in domains
|
8
|
+
* Issues Fixed:
|
9
|
+
* n/a
|
10
|
+
* Breaking Changes:
|
11
|
+
* Changed format for custom code and how data is passed back (`:custom` -> `:ha`, etc...)
|
12
|
+
* Custom function now access data through methods instead of hash
|
data/README.md
ADDED
@@ -0,0 +1,258 @@
|
|
1
|
+
# ETS project file to Home Assistant configuration
|
2
|
+
|
3
|
+
A Ruby tool to convert an ETS5 project file (`*.knxproj`) into:
|
4
|
+
|
5
|
+
* a YAML configuration file suitable for **Home Assistant** (requires to define building, functions and group data points in ETS)
|
6
|
+
* an XML file for `linknx` (the object list only)
|
7
|
+
|
8
|
+
[https://www.home-assistant.io/integrations/knx/](https://www.home-assistant.io/integrations/knx/)
|
9
|
+
|
10
|
+
## Glossary
|
11
|
+
|
12
|
+
* **KNX Group Address**: a group address is a 1, 2 or 3-level address in the KNX system, e.g. `1/2/3`.
|
13
|
+
* **KNX Data Point Type**: a data point type is a type of data that can be sent on a group address, e.g. **1.001**, **5.001**.
|
14
|
+
* **ETS Building Information**: In ETS, a building is a container for rooms and **ETS Function**.
|
15
|
+
* **ETS Function** : In ETS, represents an object that has several **KNX Group Address** associated to it.
|
16
|
+
* **HA Device** : In Home Assistant, a device has a type (e.g.`light`) and has **HA Config Variable**.
|
17
|
+
* **HA Config Variable** : In Home Assistant, it's a property of a device, e.g.`name`,`address`,`state_address`.
|
18
|
+
|
19
|
+
## Important note
|
20
|
+
|
21
|
+
In order for the tool to generate result properly read the following.
|
22
|
+
|
23
|
+
An actionable entity in KNX is a **KNX Group Address**: commands are exchanged in a group address on the KNX Bus.
|
24
|
+
For example a dimmable light has at least one group address for On/Off and another for the dimming value.
|
25
|
+
|
26
|
+
An actionable entity in **Home Assistant** is an **HA Device**.
|
27
|
+
For example, a dimmable light is a device and has properties, one of them is the group address for On/Off and another is the group address for the dimming value.
|
28
|
+
|
29
|
+
So, for this tool to work, the following pieces of information must be found:
|
30
|
+
|
31
|
+
* **KNX Group Address**
|
32
|
+
* The purpose for each **KNX Group Address** (typically, a **KNX Data Point Type**)
|
33
|
+
* Grouping of group addresses into devices : e.g. **ETS Function** in ETS mapped to **HA Device** in Home Assistant
|
34
|
+
|
35
|
+
By default, and in fact in a lot of ETS projects, only group addresses are defined as this is sufficient for an installation.
|
36
|
+
I.e. no **ETS Function** is defined, and **KNX Data Point Type**s are not defined for group addresses.
|
37
|
+
This is why this tool will not work out of the box most of the times: missing information.
|
38
|
+
The next 2 sections explain how to fix this.
|
39
|
+
|
40
|
+
### Group address: Type
|
41
|
+
|
42
|
+
**KNX Group Address**es are defined in the ETS project file naturally, as it is the base for the system to work.
|
43
|
+
But we need the types of them (e.g. **on/off** versus **dim value**) in order to create the HA configuration.
|
44
|
+
|
45
|
+
The best, easiest and most reliable way for the tool to find the type of **KNX Group Address** is to specify the **KNX Data Point Type** in the **KNX Group Address** itself in ETS.
|
46
|
+
This requires editing the KNX project.
|
47
|
+
Refer to [Structure in ETS](#structure-in-ets).
|
48
|
+
|
49
|
+
Another possibility is to create a custom script.
|
50
|
+
Refer to [Custom method](#custom-method)
|
51
|
+
|
52
|
+
### Group address: Grouping into devices
|
53
|
+
|
54
|
+
The second part is to regroup **KNX Group Address**es into **HA Device**.
|
55
|
+
This is best done by creating **ETS Function** in ETS.
|
56
|
+
Refer to [Structure in ETS](#structure-in-ets).
|
57
|
+
|
58
|
+
Another possibility is to create a custom script.
|
59
|
+
Refer to [Custom method](#custom-method)
|
60
|
+
|
61
|
+
## Installation
|
62
|
+
|
63
|
+
1. [Install Ruby for your platform](https://www.ruby-lang.org/fr/downloads/):
|
64
|
+
|
65
|
+
* Windows: [Ruby Installer](https://rubyinstaller.org/)
|
66
|
+
* macOS: builtin, or [RVM](https://rvm.io/), or [brew](https://brew.sh/), or [rbenv](https://github.com/rbenv/rbenv)
|
67
|
+
* Linux: builtin (yum, apt), or [RVM](https://rvm.io/), or [rbenv](https://github.com/rbenv/rbenv)
|
68
|
+
|
69
|
+
2. Install this gem
|
70
|
+
|
71
|
+
```bash
|
72
|
+
gem install ets-to-homeassistant
|
73
|
+
```
|
74
|
+
|
75
|
+
3. Test it works:
|
76
|
+
|
77
|
+
```bash
|
78
|
+
ets_to_hass --help
|
79
|
+
```
|
80
|
+
|
81
|
+
## Usage
|
82
|
+
|
83
|
+
General invocation syntax:
|
84
|
+
|
85
|
+
```bash
|
86
|
+
Usage: .ets_to_hass [options] <ets project file>.knxproj
|
87
|
+
|
88
|
+
-h, --help
|
89
|
+
show help
|
90
|
+
|
91
|
+
--ha-knx
|
92
|
+
include level knx in output file
|
93
|
+
|
94
|
+
--sort-by-name
|
95
|
+
sort arrays by name
|
96
|
+
|
97
|
+
--full-name
|
98
|
+
add room name in object name
|
99
|
+
|
100
|
+
--format [format]
|
101
|
+
one of homeass|linknx
|
102
|
+
|
103
|
+
--fix [ruby file]
|
104
|
+
file with specific code to fix objects
|
105
|
+
|
106
|
+
--addr [addr]
|
107
|
+
one of Free, TwoLevel, ThreeLevel
|
108
|
+
|
109
|
+
--trace [trace]
|
110
|
+
one of debug, info, warn, error
|
111
|
+
|
112
|
+
--output [file]
|
113
|
+
add room name in object name```
|
114
|
+
|
115
|
+
For example to generate the home assistant KNX configuration from the exported ETS project: `myexport.knxproj`
|
116
|
+
|
117
|
+
```bash
|
118
|
+
ets_to_hass --full-name --format homeass --output config_knx.yaml myexport.knxproj
|
119
|
+
```
|
120
|
+
|
121
|
+
Option `--ha-knx` adds the dictionary key `knx` in the generated Home Assistant configuration so that it can be copy/paste in `configuration.yaml`.
|
122
|
+
Else, typically, include the generated entities in a separate file like this:
|
123
|
+
|
124
|
+
```yaml
|
125
|
+
knx: !include config_knx.yaml
|
126
|
+
```
|
127
|
+
|
128
|
+
If option `--fix` with a path to a non-existing file, then the tool will create a template file with the default code.
|
129
|
+
It will generate basic Objects/Functions for group addresses not part of a function.
|
130
|
+
|
131
|
+
Logs are sent to STDERR.
|
132
|
+
|
133
|
+
## Internal logic
|
134
|
+
|
135
|
+
The tool takes the exported file from ETS with extension: `knxproj`.
|
136
|
+
The project file is a zip with several XML files in it.
|
137
|
+
Make sure that the project file is not password protected.
|
138
|
+
|
139
|
+
* The tool parses the first project file found.
|
140
|
+
* It extracts **ETS Building Information** and **KNX Group Address**es.
|
141
|
+
* A Ruby object of type: `EtsToHass` is created, with the following fields:
|
142
|
+
|
143
|
+
`@groups_addresses` contains all **KNX Group Address**es, `_gaid_` is the internal identifier of the **KNX Group Address** in ETS
|
144
|
+
|
145
|
+
`@objects` contains all **ETS Function**, `_obid_` is the internal identifier of the **ETS Function** in ETS
|
146
|
+
|
147
|
+
```ruby
|
148
|
+
@groups_addresses = {
|
149
|
+
_gaid_ => {
|
150
|
+
name: "from ETS",
|
151
|
+
description: "from ETS",
|
152
|
+
address: group address as string. e.g. "x/y/z" depending on project style,
|
153
|
+
datapoint: datapoint type as string "x.abc", e.g. 1.001,
|
154
|
+
ha: {address_type: '...' } # set by specific code, HA parameter for address
|
155
|
+
},...
|
156
|
+
}
|
157
|
+
@objects = {
|
158
|
+
_obid_ => {
|
159
|
+
name: "from ETS, either function name or full name with room if option --full-name is used",
|
160
|
+
type: "ETS function type, see below",
|
161
|
+
floor: "from ETS",
|
162
|
+
room: "from ETS",
|
163
|
+
ha: {domain: '...', ha parameters... } # set by specific code, HA parameters
|
164
|
+
},...
|
165
|
+
}
|
166
|
+
@associations = [[_gaid_,_obid_],...]
|
167
|
+
```
|
168
|
+
|
169
|
+
* a default mapping is proposed in methods:
|
170
|
+
|
171
|
+
* `map_ets_datapoint_to_ha_address_type` : default value for `@group_addresses[x][:ha][:address_type]`
|
172
|
+
* `map_ets_function_to_ha_object_category` : default value for `@objects[x][:ha][:domain]`
|
173
|
+
|
174
|
+
* the custom specific code is called giving an opportunity to modify this structure.
|
175
|
+
|
176
|
+
* Eventually, the HA configuration is generated
|
177
|
+
|
178
|
+
## Structure in ETS
|
179
|
+
|
180
|
+
The **KNX Data Point Type** of **KNX Group Address** is used to find out the associated **HA Config Variable**.
|
181
|
+
In ETS, for each **KNX Group Address**, make sure to define the **KNX Data Point Type** as in the following screenshot:
|
182
|
+
|
183
|
+
![datapoint in group address](images/ets5_datapoint.png)
|
184
|
+
|
185
|
+
**ETS Building Information** and **ETS Function** are used to generate HA devices.
|
186
|
+
If the ETS project has no **ETS Building Information**, then the tool will create one **HA Device** per **KNX Group Address**.
|
187
|
+
|
188
|
+
In the following screenshot, note that both **KNX Group Address** and **ETS Function** are created.
|
189
|
+
|
190
|
+
![ETS 5 with building functions](images/ets5.png)
|
191
|
+
|
192
|
+
Moreover, if **ETS Function** are located properly in **ETS Building Information** levels and rooms, the tool will read this information.
|
193
|
+
|
194
|
+
When **ETS Function** are found, the tool will populate the `@objects` `Hash`.
|
195
|
+
|
196
|
+
The type of **ETS Function** is identified by a name (in ETS project file it is `FT-[n]`):
|
197
|
+
|
198
|
+
* `:custom`
|
199
|
+
* `:switchable_light`
|
200
|
+
* `:dimmable_light`
|
201
|
+
* `:sun_protection`
|
202
|
+
* `:heating_radiator`
|
203
|
+
* `:heating_floor`
|
204
|
+
* `:heating_switching_variable`
|
205
|
+
* `:heating_continuous_variable`
|
206
|
+
|
207
|
+
## Custom method
|
208
|
+
|
209
|
+
If the **KNX Data Point Type** of a **KNX Group Address** is not defined in the ETS project, then the tool cannot guess which group address is e.g. for On/Off, or for dimming value.
|
210
|
+
|
211
|
+
If no **ETS Building Information** with **ETS Function** was created in the project, then the tool cannot guess which set of **KNX Group Address** refer to the same **HA Device**.
|
212
|
+
|
213
|
+
It is possible to add this information using option `--fix` (custom script) which can add missing information, based, for example, on the name of the **KNX Group Address**.
|
214
|
+
|
215
|
+
For example, I used to use a naming convention like: `<room>:<object>:<type>` before using ETS features, and the custom script could guess the missing data type from `<type>`, and then group addresses into devices based on `<room>` and `<object>`.
|
216
|
+
|
217
|
+
But if the convention is to place `ON/OFF` in `1/x/x` then you can use the first address identifier to guess the type of **KNX Group Address**.
|
218
|
+
|
219
|
+
The specific code can modify the analyzed structure:
|
220
|
+
|
221
|
+
* It can delete objects, or create objects.
|
222
|
+
* It can add fields in the `:ha` properties:
|
223
|
+
|
224
|
+
* in `@group_addresses`:
|
225
|
+
* `address_type` : define the use for the group address, e.g. `address`, `state_address`, etc...
|
226
|
+
* in `@objects`:
|
227
|
+
* `domain` : set the entity type in HA (`switch`, `light`, etc), this key is then removed from the `Hash`
|
228
|
+
* **Other fields** : initialize the HA object with these values, e.g. `name`
|
229
|
+
|
230
|
+
The function can use any information such as fields of the object, or description or name of group address for that.
|
231
|
+
|
232
|
+
Typically, the name of group addresses can be used if a specific naming convention was used.
|
233
|
+
Or, if group addresses were defined using a specific convention: for example in a/b/c a is the type of action, b is the identifier of device...
|
234
|
+
|
235
|
+
The specific code shall be like this:
|
236
|
+
|
237
|
+
```ruby
|
238
|
+
def fix_objects(generator)
|
239
|
+
# use methods of generator to modify the structure
|
240
|
+
end
|
241
|
+
```
|
242
|
+
|
243
|
+
## Linknx
|
244
|
+
|
245
|
+
`linknx` does not have an object concept, and needs only group addresses.
|
246
|
+
|
247
|
+
## XKNX
|
248
|
+
|
249
|
+
Support is dropped for the moment, until needed, but it is close enough to HA.
|
250
|
+
|
251
|
+
## Reporting issues
|
252
|
+
|
253
|
+
Include the version of ETS used and logs.
|
254
|
+
|
255
|
+
## TODO
|
256
|
+
|
257
|
+
One possibility would be to add extra information in the description of the group address and/or function in ETS, and then parse it in the tool.
|
258
|
+
For example, as YAML format.
|
data/bin/ets_to_hass
ADDED
@@ -0,0 +1,111 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# add in case we are in dev
|
5
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__dir__), 'lib'))
|
6
|
+
|
7
|
+
begin
|
8
|
+
require 'ets_to_hass/string_colors'
|
9
|
+
require 'ets_to_hass/generator'
|
10
|
+
require 'getoptlong'
|
11
|
+
rescue LoadError => e
|
12
|
+
puts(e.backtrace.join("\n"))
|
13
|
+
puts("Missing gems (#{e}): read the manual: execute:")
|
14
|
+
puts("gem install bundler\nbundle install".blink)
|
15
|
+
exit(1)
|
16
|
+
end
|
17
|
+
|
18
|
+
# prefix of generation methods
|
19
|
+
GENE_PREFIX = 'generate_'
|
20
|
+
# get list of generation methods
|
21
|
+
gene_formats = (EtsToHass::Generator.instance_methods - EtsToHass::Generator.superclass.instance_methods)
|
22
|
+
.select { |m| m.to_s.start_with?(GENE_PREFIX) }
|
23
|
+
.map { |m| m[GENE_PREFIX.length..-1] }
|
24
|
+
|
25
|
+
opts = GetoptLong.new(
|
26
|
+
['--help', '-h', GetoptLong::NO_ARGUMENT],
|
27
|
+
['--ha-knx', '-k', GetoptLong::NO_ARGUMENT],
|
28
|
+
['--sort-by-name', '-r', GetoptLong::NO_ARGUMENT],
|
29
|
+
['--full-name', '-n', GetoptLong::NO_ARGUMENT],
|
30
|
+
['--format', '-f', GetoptLong::REQUIRED_ARGUMENT],
|
31
|
+
['--fix', '-s', GetoptLong::REQUIRED_ARGUMENT],
|
32
|
+
['--addr', '-a', GetoptLong::REQUIRED_ARGUMENT],
|
33
|
+
['--trace', '-t', GetoptLong::REQUIRED_ARGUMENT],
|
34
|
+
['--output', '-o', GetoptLong::REQUIRED_ARGUMENT]
|
35
|
+
)
|
36
|
+
|
37
|
+
options = {}
|
38
|
+
output_format = 'homeass'
|
39
|
+
opts.each do |opt, arg|
|
40
|
+
case opt
|
41
|
+
when '--help'
|
42
|
+
puts <<~END_OF_MANUAL
|
43
|
+
Usage: #{$PROGRAM_NAME} [options] <ets project file>.knxproj
|
44
|
+
|
45
|
+
-h, --help
|
46
|
+
show help
|
47
|
+
|
48
|
+
--ha-knx
|
49
|
+
include level knx in output file
|
50
|
+
|
51
|
+
--sort-by-name
|
52
|
+
sort arrays by name
|
53
|
+
|
54
|
+
--full-name
|
55
|
+
add room name in object name
|
56
|
+
|
57
|
+
--format [format]
|
58
|
+
one of #{gene_formats.join('|')}
|
59
|
+
|
60
|
+
--fix [ruby file]
|
61
|
+
file with specific code to fix objects
|
62
|
+
|
63
|
+
--addr [addr]
|
64
|
+
one of #{EtsToHass::Generator::GROUP_ADDRESS_PARSERS.keys.map(&:to_s).join(', ')}
|
65
|
+
|
66
|
+
--trace [trace]
|
67
|
+
one of debug, info, warn, error
|
68
|
+
|
69
|
+
--output [file]
|
70
|
+
add room name in object name
|
71
|
+
END_OF_MANUAL
|
72
|
+
Process.exit(1)
|
73
|
+
when '--fix'
|
74
|
+
options[:specific] = arg
|
75
|
+
when '--format'
|
76
|
+
output_format = arg
|
77
|
+
raise "Error: no such output format: #{output_format}" unless gene_formats.include?(output_format)
|
78
|
+
when '--ha-knx'
|
79
|
+
options[:ha_knx] = true
|
80
|
+
when '--sort-by-name'
|
81
|
+
options[:sort_by_name] = true
|
82
|
+
when '--full-name'
|
83
|
+
options[:full_name] = true
|
84
|
+
when '--trace'
|
85
|
+
options[:trace] = arg
|
86
|
+
when '--addr'
|
87
|
+
options[:addr] = arg
|
88
|
+
when '--output'
|
89
|
+
options[:output] = arg
|
90
|
+
else
|
91
|
+
raise "Unknown option #{opt}"
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
if ARGV.length != 1
|
96
|
+
puts 'Missing project file argument (try --help)'
|
97
|
+
Process.exit(1)
|
98
|
+
end
|
99
|
+
|
100
|
+
output_file =
|
101
|
+
if options[:output]
|
102
|
+
File.open(options[:output], 'w')
|
103
|
+
else
|
104
|
+
$stdout
|
105
|
+
end
|
106
|
+
project_file_path = ARGV.shift
|
107
|
+
|
108
|
+
# read and parse ETS file
|
109
|
+
generator = EtsToHass::Generator.new(project_file_path, options)
|
110
|
+
# generate result (e.g. call generate_homeass)
|
111
|
+
output_file.write(generator.send(:"#{GENE_PREFIX}#{output_format}"))
|
@@ -0,0 +1,364 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'zip'
|
4
|
+
require 'xmlsimple'
|
5
|
+
require 'yaml'
|
6
|
+
require 'json'
|
7
|
+
require 'logger'
|
8
|
+
require 'fileutils'
|
9
|
+
require 'ets_to_hass/string_colors'
|
10
|
+
require 'ets_to_hass/info'
|
11
|
+
|
12
|
+
module EtsToHass
|
13
|
+
# Import ETS project file and generate configuration for Home Assistant and KNXWeb
|
14
|
+
class Generator
|
15
|
+
# extension of ETS project file
|
16
|
+
ETS_EXT = '.knxproj'
|
17
|
+
# converters of group address integer address into representation
|
18
|
+
GROUP_ADDRESS_PARSERS = {
|
19
|
+
Free: ->(a) { a.to_s },
|
20
|
+
TwoLevel: ->(a) { [(a >> 11) & 31, a & 2047].join('/') },
|
21
|
+
ThreeLevel: ->(a) { [(a >> 11) & 31, (a >> 8) & 7, a & 255].join('/') }
|
22
|
+
}.freeze
|
23
|
+
# KNX functions described in knx_master.xml in project file, in <FunctionTypes>
|
24
|
+
# map index parsed from "FT-x" to recognizable identifier
|
25
|
+
ETS_FUNCTIONS_INDEX_TO_NAME =
|
26
|
+
%i[custom switchable_light dimmable_light sun_protection heating_radiator heating_floor
|
27
|
+
dimmable_light sun_protection heating_switching_variable heating_continuous_variable].freeze
|
28
|
+
private_constant :ETS_EXT, :ETS_FUNCTIONS_INDEX_TO_NAME
|
29
|
+
|
30
|
+
# class methods
|
31
|
+
class << self
|
32
|
+
# helper function to dig through keys, knowing that we used ForceArray
|
33
|
+
def dig_xml(entry_point, path)
|
34
|
+
raise "ERROR: wrong entry point: #{entry_point.class}, expect Hash" unless entry_point.is_a?(Hash)
|
35
|
+
|
36
|
+
path.each do |n|
|
37
|
+
raise "ERROR: cannot find level #{n} in xml, have #{entry_point.keys.join(',')}" unless entry_point.key?(n)
|
38
|
+
|
39
|
+
entry_point = entry_point[n]
|
40
|
+
# because we use ForceArray
|
41
|
+
entry_point = entry_point.first
|
42
|
+
raise "ERROR: expect array with one element in #{n}" if entry_point.nil?
|
43
|
+
end
|
44
|
+
entry_point
|
45
|
+
end
|
46
|
+
|
47
|
+
def function_type_to_name(ft_type)
|
48
|
+
m = ft_type.match(/^FT-([0-9])$/)
|
49
|
+
raise "ERROR: Unknown function type: #{ft_type}" if m.nil?
|
50
|
+
|
51
|
+
ETS_FUNCTIONS_INDEX_TO_NAME[m[1].to_i]
|
52
|
+
end
|
53
|
+
|
54
|
+
def specific_defined?
|
55
|
+
defined?(fix_objects).eql?('method')
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def initialize(file, options = {})
|
60
|
+
# command line options
|
61
|
+
@opts = options
|
62
|
+
# parsed data: ob: objects (ETS functions), ga: group addresses
|
63
|
+
@objects = {}
|
64
|
+
@group_addresses = {}
|
65
|
+
@associations = []
|
66
|
+
# log to stderr, so that redirecting stdout captures only generated data
|
67
|
+
@logger = Logger.new($stderr)
|
68
|
+
@logger.level = @opts.key?(:trace) ? @opts[:trace] : Logger::INFO
|
69
|
+
@logger.debug("options: #{@opts}")
|
70
|
+
# read .knxproj file xml into project variable
|
71
|
+
project = read_file(file)
|
72
|
+
# find out address style
|
73
|
+
proj_info = self.class.dig_xml(project[:info], %w[Project ProjectInformation])
|
74
|
+
@group_addr_style = (@opts[:addr] || proj_info['GroupAddressStyle']).to_sym
|
75
|
+
raise "Error: no such style #{@group_addr_style} in #{GROUP_ADDRESS_PARSERS.keys}" if GROUP_ADDRESS_PARSERS[@group_addr_style].nil?
|
76
|
+
|
77
|
+
@logger.info("Using project #{proj_info['Name']}, address style: #{@group_addr_style}")
|
78
|
+
# locate main node in xml
|
79
|
+
installation = self.class.dig_xml(project[:data], %w[Project Installations Installation])
|
80
|
+
# process group ranges: fill @group_addresses
|
81
|
+
process_group_ranges(self.class.dig_xml(installation, %w[GroupAddresses GroupRanges]))
|
82
|
+
# process group ranges: fill @objects (for 2 versions of ETS which have different tags?)
|
83
|
+
process_space(self.class.dig_xml(installation, ['Locations']), 'Space') if installation.key?('Locations')
|
84
|
+
process_space(self.class.dig_xml(installation, ['Buildings']), 'BuildingPart') if installation.key?('Buildings')
|
85
|
+
@logger.warn('No building information found.') if @objects.keys.empty?
|
86
|
+
return unless @opts[:specific]
|
87
|
+
unless File.exist?(@opts[:specific])
|
88
|
+
@logger.warn("Specific code file #{@opts[:specific]} not found, creating generic one.")
|
89
|
+
FileUtils.cp(File.join(File.dirname(__FILE__), 'specific', 'generic.rb'), @opts[:specific])
|
90
|
+
end
|
91
|
+
# load specific code
|
92
|
+
load(@opts[:specific])
|
93
|
+
raise "no method found in #{@opts[:specific]}" unless self.class.specific_defined?
|
94
|
+
end
|
95
|
+
|
96
|
+
def warning(entity, name, message)
|
97
|
+
@logger.warn("#{entity.red} #{name.green} #{message}")
|
98
|
+
end
|
99
|
+
|
100
|
+
# format the integer group address to string in desired style (e.g. 1/2/3)
|
101
|
+
def parse_group_address(group_address)
|
102
|
+
GROUP_ADDRESS_PARSERS[@group_addr_style].call(group_address.to_i).freeze
|
103
|
+
end
|
104
|
+
|
105
|
+
# Read both project.xml and 0.xml
|
106
|
+
# @return Hash {info: xml data, data: xml data}
|
107
|
+
def read_file(file)
|
108
|
+
raise "ETS file must end with #{ETS_EXT}" unless file.end_with?(ETS_EXT)
|
109
|
+
|
110
|
+
project = {}
|
111
|
+
# read ETS5 file and get project file
|
112
|
+
Zip::File.open(file) do |zip_file|
|
113
|
+
zip_file.each do |entry|
|
114
|
+
case entry.name
|
115
|
+
when %r{P-[^/]+/project\.xml$}
|
116
|
+
project[:info] = XmlSimple.xml_in(entry.get_input_stream.read, { 'ForceArray' => true })
|
117
|
+
when %r{P-[^/]+/0\.xml$}
|
118
|
+
project[:data] = XmlSimple.xml_in(entry.get_input_stream.read, { 'ForceArray' => true })
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
raise "Did not find project information or data (#{project.keys})" unless project.keys.sort.eql?(%i[data info])
|
123
|
+
|
124
|
+
project
|
125
|
+
end
|
126
|
+
|
127
|
+
# process group range recursively and find addresses
|
128
|
+
def process_group_ranges(group)
|
129
|
+
group['GroupRange'].each { |sgr| process_group_ranges(sgr) } if group.key?('GroupRange')
|
130
|
+
group['GroupAddress'].each { |group_address| process_ga(group_address) } if group.key?('GroupAddress')
|
131
|
+
end
|
132
|
+
|
133
|
+
# process a group address
|
134
|
+
def process_ga(group_address)
|
135
|
+
# build object for each group address
|
136
|
+
group = {
|
137
|
+
name: group_address['Name'].freeze, # ETS: name field
|
138
|
+
description: group_address['Description'].freeze, # ETS: description field
|
139
|
+
address: parse_group_address(group_address['Address']), # group address as string. e.g. "x/y/z" depending on project style
|
140
|
+
datapoint: nil, # datapoint type as string "x.00y"
|
141
|
+
ha: { address_type: nil } # prepared to be potentially modified by specific code
|
142
|
+
}
|
143
|
+
if group_address['DatapointType'].nil?
|
144
|
+
warning(group[:address], group[:name], 'no datapoint type for address group, to be defined in ETS, skipping')
|
145
|
+
return
|
146
|
+
end
|
147
|
+
# parse datapoint for easier use
|
148
|
+
if (m = group_address['DatapointType'].match(/^DPST-([0-9]+)-([0-9]+)$/))
|
149
|
+
# datapoint type as string x.00y
|
150
|
+
group[:datapoint] = format('%d.%03d', m[1].to_i, m[2].to_i) # no freeze
|
151
|
+
else
|
152
|
+
warning(group[:address], group[:name],
|
153
|
+
"Cannot parse data point type: #{group_address['DatapointType']} (DPST-x-x), skipping")
|
154
|
+
return
|
155
|
+
end
|
156
|
+
# Index is the internal Id in xml file
|
157
|
+
@group_addresses[group_address['Id'].freeze] = group.freeze
|
158
|
+
@logger.debug("group: #{group}")
|
159
|
+
end
|
160
|
+
|
161
|
+
# process locations recursively, and find functions
|
162
|
+
# @param space the current space
|
163
|
+
# @param space_type the type of space: Space or BuildingPart
|
164
|
+
# @param info current location information: floor, room
|
165
|
+
def process_space(space, space_type, info = nil)
|
166
|
+
info = info.nil? ? {} : info.dup
|
167
|
+
@logger.debug("space: #{space['Type']}: #{space['Name']} (#{info})")
|
168
|
+
# process building sub spaces
|
169
|
+
if space.key?(space_type)
|
170
|
+
# get floor when we have it
|
171
|
+
info[:floor] = space['Name'] if space['Type'].eql?('Floor')
|
172
|
+
# recursively process sub-spaces
|
173
|
+
space[space_type].each { |s| process_space(s, space_type, info) }
|
174
|
+
end
|
175
|
+
# Functions are objects
|
176
|
+
return unless space.key?('Function')
|
177
|
+
|
178
|
+
# we assume the object is directly in the room
|
179
|
+
info[:room] = space['Name']
|
180
|
+
# loop on group addresses
|
181
|
+
space['Function'].each do |ets_function|
|
182
|
+
# @logger.debug("function #{ets_function}")
|
183
|
+
# ignore functions without group address
|
184
|
+
next unless ets_function.key?('GroupAddressRef')
|
185
|
+
|
186
|
+
# the ETS object, created from ETS function
|
187
|
+
ets_object = {
|
188
|
+
name: ets_function['Name'].freeze,
|
189
|
+
type: self.class.function_type_to_name(ets_function['Type']),
|
190
|
+
ha: { domain: nil } # hone assistant values
|
191
|
+
}.merge(info)
|
192
|
+
add_object(ets_function['Id'], ets_object)
|
193
|
+
ets_function['GroupAddressRef'].map { |g| g['RefId'].freeze }.each do |group_address_id|
|
194
|
+
associate(ga_id: group_address_id, object_id: ets_function['Id'])
|
195
|
+
end
|
196
|
+
@logger.debug("function: #{ets_object}")
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
# map ETS function to home assistant object type
|
201
|
+
# see https://www.home-assistant.io/integrations/knx/
|
202
|
+
def map_ets_function_to_ha_object_category(ets_func)
|
203
|
+
# map FT-x type to home assistant type
|
204
|
+
case ets_func[:type]
|
205
|
+
when :switchable_light, :dimmable_light then 'light'
|
206
|
+
when :sun_protection then 'cover'
|
207
|
+
when :custom, :heating_continuous_variable, :heating_floor, :heating_radiator, :heating_switching_variable
|
208
|
+
@logger.warn("#{ets_func[:room].red} #{ets_func[:name].green} function type #{ets_func[:type].to_s.blue} not implemented")
|
209
|
+
nil
|
210
|
+
else @logger.error("#{ets_func[:room].red} #{ets_func[:name].green} function type #{ets_func[:type].to_s.blue} not supported, please report")
|
211
|
+
nil
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# map datapoint to home assistant type
|
216
|
+
# see https://www.home-assistant.io/integrations/knx/
|
217
|
+
def map_ets_datapoint_to_ha_address_type(group_address, ha_object_domain)
|
218
|
+
case group_address[:datapoint]
|
219
|
+
when '1.001' then 'address' # switch on/off or state
|
220
|
+
when '1.008' then 'move_long_address' # up/down
|
221
|
+
when '1.010' then 'stop_address' # stop
|
222
|
+
when '1.011' then 'state_address' # switch state
|
223
|
+
when '3.007'
|
224
|
+
@logger.debug("#{group_address[:address]}(#{ha_object_domain}:#{group_address[:datapoint]}:#{group_address[:name]}): ignoring datapoint")
|
225
|
+
nil # dimming control: used by buttons
|
226
|
+
when '5.001' # percentage 0-100
|
227
|
+
# user-provided code tells what is state
|
228
|
+
case ha_object_domain
|
229
|
+
when 'light' then 'brightness_address'
|
230
|
+
when 'cover' then 'position_address'
|
231
|
+
else
|
232
|
+
warning(group_address[:address], group_address[:name], "#{group_address[:datapoint]} expects: light or cover, not #{ha_object_domain.magenta}")
|
233
|
+
nil
|
234
|
+
end
|
235
|
+
else
|
236
|
+
warning(group_address[:address], group_address[:name], "un-managed datapoint #{group_address[:datapoint].blue} (#{ha_object_domain.magenta})")
|
237
|
+
nil
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
# @param ga_id group address id
|
242
|
+
# @returns array of objects for this group id
|
243
|
+
def ga_object_ids(ga_id)
|
244
|
+
@associations.select { |a| a[:ga_id].eql?(ga_id) }.map { |a| a[:object_id] }
|
245
|
+
end
|
246
|
+
|
247
|
+
def all_object_ids
|
248
|
+
@objects.keys
|
249
|
+
end
|
250
|
+
|
251
|
+
# @param object_id object id
|
252
|
+
# @returns array of group addresses for this object
|
253
|
+
def object_ga_ids(object_id)
|
254
|
+
@associations.select { |a| a[:object_id].eql?(object_id) }.map { |a| a[:ga_id] }
|
255
|
+
end
|
256
|
+
|
257
|
+
def all_ga_ids
|
258
|
+
@group_addresses.keys
|
259
|
+
end
|
260
|
+
|
261
|
+
def associate(ga_id:, object_id:)
|
262
|
+
@associations.push({ ga_id: ga_id, object_id: object_id })
|
263
|
+
end
|
264
|
+
|
265
|
+
def delete_object(object_id)
|
266
|
+
@objects.delete(object_id)
|
267
|
+
@associations.delete_if { |a| a[:object_id].eql?(object_id) }
|
268
|
+
end
|
269
|
+
|
270
|
+
def add_object(object_id, object)
|
271
|
+
# objects will not be modified, this shall be what comes from ETS only, use field `ha` for specific code
|
272
|
+
@objects[object_id] = object.freeze
|
273
|
+
end
|
274
|
+
|
275
|
+
def object(object_id)
|
276
|
+
@objects[object_id]
|
277
|
+
end
|
278
|
+
|
279
|
+
def group_address_data(ga_id)
|
280
|
+
@group_addresses[ga_id]
|
281
|
+
end
|
282
|
+
|
283
|
+
# This creates the Home Assistant configuration in variable ha_config
|
284
|
+
# based on data coming from ETS
|
285
|
+
# and optionally modified by specific code in apply_specific
|
286
|
+
def generate_homeass
|
287
|
+
# First, apply user-provided specific code
|
288
|
+
if self.class.specific_defined?
|
289
|
+
@logger.info("Applying fix code from #{@opts[:specific]}")
|
290
|
+
fix_objects(self)
|
291
|
+
end
|
292
|
+
# This will be the YAML for HA
|
293
|
+
ha_config = {}
|
294
|
+
# warn of group addresses that will not be used (you can fix in specific code)
|
295
|
+
all_ga_ids.each do |ga_id|
|
296
|
+
next unless ga_object_ids(ga_id).empty?
|
297
|
+
ga_data = group_address_data(ga_id)
|
298
|
+
warning(ga_data[:address], ga_data[:name], 'Group not in object: use ETS to create functions or use specific code')
|
299
|
+
end
|
300
|
+
# Generate devices from either functions in ETS, or from specific code
|
301
|
+
all_object_ids.each do |object_id|
|
302
|
+
ets_object = object(object_id)
|
303
|
+
# compute object domain, this is the section in HA configuration (switch, light, etc...)
|
304
|
+
ha_object_domain = ets_object[:ha].delete(:domain) || map_ets_function_to_ha_object_category(ets_object)
|
305
|
+
if ha_object_domain.nil?
|
306
|
+
warning(ets_object[:name], ets_object[:room], "#{ets_object[:type].to_s.blue}: function type not detected, skipping")
|
307
|
+
next
|
308
|
+
end
|
309
|
+
# add domain to config, if necessary
|
310
|
+
ha_config[ha_object_domain] ||= []
|
311
|
+
# HA configuration object, either empty or initialized in specific code
|
312
|
+
ha_device = ets_object[:ha]
|
313
|
+
# default name
|
314
|
+
ha_device['name'] ||= @opts[:full_name] ? "#{ets_object[:name]} #{ets_object[:room]}" : ets_object[:name]
|
315
|
+
# check name is not duplicated, as name is used to identify the object
|
316
|
+
if ha_config[ha_object_domain].any? { |v| v['name'].casecmp?(ha_device['name']) }
|
317
|
+
@logger.warn("#{ha_device['name'].red} object name is duplicated (check ETS objects and rooms)")
|
318
|
+
end
|
319
|
+
# add object to configuration
|
320
|
+
ha_config[ha_object_domain].push(ha_device)
|
321
|
+
# process all group addresses in ETS function (object)
|
322
|
+
object_ga_ids(object_id).each do |group_address_id|
|
323
|
+
# get this group information
|
324
|
+
ga_data = group_address_data(group_address_id)
|
325
|
+
if ga_data.nil?
|
326
|
+
@logger.error("#{ets_object[:name].red} #{ets_object[:room].green} (#{ets_object[:type].to_s.magenta}): #{group_address_id}: group address not found, skipping")
|
327
|
+
next
|
328
|
+
end
|
329
|
+
# find HA property name based on datapoint
|
330
|
+
ha_address_type = ga_data[:ha][:address_type] || map_ets_datapoint_to_ha_address_type(ga_data, ha_object_domain)
|
331
|
+
next if ha_address_type.eql?(:ignore)
|
332
|
+
if ha_address_type.nil?
|
333
|
+
warning(ga_data[:address], ga_data[:name],
|
334
|
+
"#{ga_data[:datapoint].blue} / #{ha_object_domain.magenta}: address type not detected, skipping")
|
335
|
+
next
|
336
|
+
end
|
337
|
+
if ha_device.key?(ha_address_type)
|
338
|
+
@logger.error("#{ga_data[:address].red} #{ga_data[:name].green} (#{ha_object_domain.magenta}:#{ga_data[:datapoint]}) #{ha_address_type} already set with #{ha_device[ha_address_type]}, skipping")
|
339
|
+
# TODO: support passive addresses ?
|
340
|
+
next
|
341
|
+
end
|
342
|
+
# ok, we have the property name and group address
|
343
|
+
ha_device[ha_address_type] = ga_data[:address]
|
344
|
+
end
|
345
|
+
end
|
346
|
+
if @opts[:sort_by_name]
|
347
|
+
@logger.info('Sorting by name')
|
348
|
+
ha_config.each_value { |v| v.sort_by! { |o| o['name'] } }
|
349
|
+
end
|
350
|
+
# add knx level, if user asks for it
|
351
|
+
ha_config = { 'knx' => ha_config } if @opts[:ha_knx]
|
352
|
+
ha_config.to_yaml
|
353
|
+
end
|
354
|
+
|
355
|
+
# https://sourceforge.net/p/linknx/wiki/Object_Definition_section/
|
356
|
+
def generate_linknx
|
357
|
+
@group_addresses.values.sort { |a, b| a[:address] <=> b[:address] }.map do |ga_data|
|
358
|
+
linknx_name = ga_data[:name]
|
359
|
+
linknx_id = "id_#{ga_data[:address].gsub('/', '_')}"
|
360
|
+
%Q( <object type="#{ga_data[:datapoint]}" id="#{linknx_id}" gad="#{ga_data[:address]}" init="request">#{linknx_name}</object>)
|
361
|
+
end.join("\n")
|
362
|
+
end
|
363
|
+
end
|
364
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EtsToHass
|
4
|
+
NAME = 'ets-to-homeassistant'
|
5
|
+
VERSION = '0.1'
|
6
|
+
SRC_URL = 'https://github.com/laurent-martin/ets-to-homeassistant'
|
7
|
+
GEM_URL = 'https://github.com/laurent-martin/ets-to-homeassistant'
|
8
|
+
DOC_URL = 'https://github.com/laurent-martin/ets-to-homeassistant'
|
9
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This example of custom method creates one object per group address.
|
4
|
+
# It's not right, but it's simple.
|
5
|
+
|
6
|
+
def fix_objects(generator)
|
7
|
+
# generate new objects sequentially
|
8
|
+
new_object_id = 0
|
9
|
+
# loop on group addresses
|
10
|
+
generator.all_ga_ids.each do |ga_id|
|
11
|
+
# skip if group address already assigned to an object
|
12
|
+
next unless generator.ga_object_ids(ga_id).empty?
|
13
|
+
ga_data = generator.group_address_data(ga_id)
|
14
|
+
# generate a dummy object with a single group address
|
15
|
+
generator.add_object(
|
16
|
+
new_object_id, {
|
17
|
+
name: ga_data[:name],
|
18
|
+
type: :custom, # unknown type of object, switch ?
|
19
|
+
floor: 'unknown floor',
|
20
|
+
room: 'unknown room',
|
21
|
+
ha: { domain: 'switch' }
|
22
|
+
}
|
23
|
+
)
|
24
|
+
generator.associate(ga_id: ga_id, object_id: new_object_id)
|
25
|
+
# prepare next object identifier
|
26
|
+
new_object_id += 1
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# I can detect HA object address type based on group address
|
4
|
+
GROUP_TO_ADDR_TYPE = {
|
5
|
+
'/1/' => 'state_address',
|
6
|
+
'/4/' => 'brightness_state_address'
|
7
|
+
}.freeze
|
8
|
+
DATAPOINT_TO_ADDR_TYPE = {
|
9
|
+
'3.007' => :ignore # action "up" / "down"
|
10
|
+
}.freeze
|
11
|
+
|
12
|
+
# Laurent's specific code for KNX configuration
|
13
|
+
def fix_objects(generator)
|
14
|
+
# loop on objects to find blinds
|
15
|
+
# I have two types of blinds: normal and special
|
16
|
+
# "normal" blinds have a single GA for up/down
|
17
|
+
# "special" blinds have 2 GA: one for up, one for down, and are managed like a switch, so I declare 2 objects for them
|
18
|
+
generator.all_object_ids.each do |obj_id|
|
19
|
+
object = generator.object(obj_id)
|
20
|
+
# customs are switches
|
21
|
+
object[:ha][:domain] ||= 'switch' if object[:type].eql?(:custom)
|
22
|
+
# need only to manage covers/blinds
|
23
|
+
next unless object[:type].eql?(:sun_protection)
|
24
|
+
group_ids = object_ga_ids(obj_id)
|
25
|
+
# manage in special manner my blinds, identified by "pulse" in address group name
|
26
|
+
if group_address_data(group_ids.first)[:name].end_with?(':Pulse')
|
27
|
+
# delete current object: will be split into 2
|
28
|
+
generator.delete_object(obj_id)
|
29
|
+
# loop on GA for this object (one for up and one for down)
|
30
|
+
group_ids.each do |ga_id|
|
31
|
+
ga_data = group_address_data(ga_id)
|
32
|
+
# get direction of GA based on name
|
33
|
+
direction =
|
34
|
+
case ga_data[:name]
|
35
|
+
when /Montee/ then 'Montee'
|
36
|
+
when /Descente/ then 'Descente'
|
37
|
+
else raise "error: #{ga_data[:name]}"
|
38
|
+
end
|
39
|
+
# fix datapoint for GA (I have set up/down in ETS)
|
40
|
+
ga_data[:datapoint].replace('1.001')
|
41
|
+
new_object_id = "#{obj_id}_#{direction}"
|
42
|
+
# create new object
|
43
|
+
generator.add_object(
|
44
|
+
new_object_id,
|
45
|
+
{
|
46
|
+
name: "#{object[:name]} #{direction}",
|
47
|
+
type: :custom, # simple switch
|
48
|
+
floor: object[:floor],
|
49
|
+
room: object[:room],
|
50
|
+
ha: { domain: 'switch' }
|
51
|
+
}
|
52
|
+
)
|
53
|
+
generator.associate(ga_id: ga_id, object_id: new_object_id)
|
54
|
+
end
|
55
|
+
else
|
56
|
+
# set my specific times
|
57
|
+
object[:ha].merge!(
|
58
|
+
{ 'travelling_time_down' => 59,
|
59
|
+
'travelling_time_up' => 59 }
|
60
|
+
)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
# lights: I use x/1/x for state and x/4/x for brightness state
|
64
|
+
generator.all_ga_ids.each do |ga_id|
|
65
|
+
ga_data = generator.group_address_data(ga_id)
|
66
|
+
GROUP_TO_ADDR_TYPE.each do |pattern, addr_type|
|
67
|
+
ga_data[:ha][:address_type] = addr_type if ga_data[:address].include?(pattern)
|
68
|
+
end
|
69
|
+
DATAPOINT_TO_ADDR_TYPE.each do |pattern, addr_type|
|
70
|
+
ga_data[:ha][:address_type] = addr_type if ga_data[:datapoint].eql?(pattern)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This example of custom method uses group address description to figure out devices
|
4
|
+
PREFIX = {
|
5
|
+
'ECL_On/Off ' => { address_type: 'address', domain: 'light' },
|
6
|
+
# 'ECL_VAL ' => {address_type: 'todo1',domain: 'light'},
|
7
|
+
# 'État_ECL_VAL ' => {address_type: 'todo2',domain: 'light'},
|
8
|
+
'État_ECL_On/Off ' => { address_type: 'state_address', domain: 'light' },
|
9
|
+
'ECL_VAR ' => { address_type: 'brightness_address', domain: 'light' },
|
10
|
+
'Pos._VR_% ' => { address_type: 'position_address', domain: 'cover' },
|
11
|
+
'M/D_VR ' => { address_type: 'move_long_address', domain: 'cover' }
|
12
|
+
# 'État_Pos._VR_% ' => {address_type: 'todo',domain: 'cover'},
|
13
|
+
# 'T°_Amb. ' => {address_type: 'type4',domain: 'light'},
|
14
|
+
# 'Dét._Prés. ' => {address_type: 'type4',domain: 'light'},
|
15
|
+
}.freeze
|
16
|
+
|
17
|
+
# generate
|
18
|
+
def fix_objects(generator)
|
19
|
+
# loop on group addresses
|
20
|
+
generator.all_ga_ids.each do |ga_id|
|
21
|
+
# ignore if the group address is already in an object
|
22
|
+
next unless generator.ga_object_ids(ga_id).empty?
|
23
|
+
ga_data = generator.group_address_data(ga_id)
|
24
|
+
obj_name = nil
|
25
|
+
object_domain = nil
|
26
|
+
# try to guess an object name from group address name
|
27
|
+
PREFIX.each do |prefix, info|
|
28
|
+
next unless ga_data[:name].start_with?(prefix)
|
29
|
+
obj_name = ga_data[:name][prefix.length..]
|
30
|
+
ga_data[:ha][:address_type] = info[:address_type]
|
31
|
+
object_domain = info[:domain]
|
32
|
+
break
|
33
|
+
end
|
34
|
+
if obj_name.nil?
|
35
|
+
warn("unknown:#{ga_data}")
|
36
|
+
next
|
37
|
+
end
|
38
|
+
|
39
|
+
# use name as id, so that we can easily group GAs
|
40
|
+
obj_id = obj_name
|
41
|
+
|
42
|
+
if generator.object(obj_id).nil?
|
43
|
+
generator.add_object(
|
44
|
+
obj_id, {
|
45
|
+
name: obj_name,
|
46
|
+
type: :custom, # unknown, so assume just switch
|
47
|
+
floor: 'unknown floor',
|
48
|
+
room: 'unknown room',
|
49
|
+
ha: { domain: object_domain }
|
50
|
+
}
|
51
|
+
)
|
52
|
+
end
|
53
|
+
generator.associate(ga_id: ga_id, object_id: obj_id)
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Add colors
|
4
|
+
class String
|
5
|
+
class << self
|
6
|
+
private
|
7
|
+
|
8
|
+
def vt_cmd(code)
|
9
|
+
"\e[#{code}m"
|
10
|
+
end
|
11
|
+
end
|
12
|
+
# see https://en.wikipedia.org/wiki/ANSI_escape_code
|
13
|
+
# symbol is the method name added to String
|
14
|
+
# it adds control chars to set color (and reset at the end).
|
15
|
+
VT_STYLES = {
|
16
|
+
bold: 1,
|
17
|
+
dim: 2,
|
18
|
+
italic: 3,
|
19
|
+
underline: 4,
|
20
|
+
blink: 5,
|
21
|
+
reverse_color: 7,
|
22
|
+
invisible: 8,
|
23
|
+
black: 30,
|
24
|
+
red: 31,
|
25
|
+
green: 32,
|
26
|
+
brown: 33,
|
27
|
+
blue: 34,
|
28
|
+
magenta: 35,
|
29
|
+
cyan: 36,
|
30
|
+
gray: 37,
|
31
|
+
bg_black: 40,
|
32
|
+
bg_red: 41,
|
33
|
+
bg_green: 42,
|
34
|
+
bg_brown: 43,
|
35
|
+
bg_blue: 44,
|
36
|
+
bg_magenta: 45,
|
37
|
+
bg_cyan: 46,
|
38
|
+
bg_gray: 47
|
39
|
+
}.freeze
|
40
|
+
private_constant :VT_STYLES
|
41
|
+
# defines methods to String, one per entry in VT_STYLES
|
42
|
+
VT_STYLES.each do |name, code|
|
43
|
+
if $stderr.tty?
|
44
|
+
begin_seq = vt_cmd(code)
|
45
|
+
end_code = 0 # by default reset all
|
46
|
+
if code <= 7 then code + 20
|
47
|
+
elsif code <= 37 then 39
|
48
|
+
elsif code <= 47 then 49
|
49
|
+
end
|
50
|
+
end_seq = vt_cmd(end_code)
|
51
|
+
define_method(name) { "#{begin_seq}#{self}#{end_seq}" }
|
52
|
+
else
|
53
|
+
define_method(name) { self }
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
data.tar.gz.sig
ADDED
Binary file
|
metadata
ADDED
@@ -0,0 +1,253 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: ets-to-homeassistant
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: '0.1'
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Laurent Martin
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain:
|
11
|
+
- |
|
12
|
+
-----BEGIN CERTIFICATE-----
|
13
|
+
MIIDkjCCAnqgAwIBAgIBATANBgkqhkiG9w0BAQsFADBHMRkwFwYDVQQDDBBsYXVy
|
14
|
+
ZW50Lm1hcnRpbi5sMRUwEwYKCZImiZPyLGQBGRYFZ21haWwxEzARBgoJkiaJk/Is
|
15
|
+
ZAEZFgNjb20wHhcNMjMxMjMxMTc1NjA2WhcNMjcwMTA0MTc1NjA2WjBHMRkwFwYD
|
16
|
+
VQQDDBBsYXVyZW50Lm1hcnRpbi5sMRUwEwYKCZImiZPyLGQBGRYFZ21haWwxEzAR
|
17
|
+
BgoJkiaJk/IsZAEZFgNjb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB
|
18
|
+
AQDEkaJ9DiTfuwZ00qWDbyb7jwZvYsuMCQ96gkoKj+97fUUq8PtZBQd88KAdCHYA
|
19
|
+
FAYQ2oeUz6viAylMkg5RUqd4CxnFU7Udozmnw3qyQksUsxLjbBnhg8Ye5icsumT8
|
20
|
+
T0O9jlKJ0xKW24INLghVrmyrh8qzH4EcwAUceuHB0BziMP2ZfuaiDvmEVruEmWvE
|
21
|
+
FS2B3m+JJYEfhLjHN80f/G2P3kvN44r1to/R5wBjgxvtvaHWv2amwBGZbOB1c3Sg
|
22
|
+
qdi7laIZMccF02+KfGBXbuc28b75NItFUK+62eLgSCi9/GJwZ4HC5lGpF7ac+S66
|
23
|
+
upj5v4RdZMx9sTIWfoNugI5TAgMBAAGjgYgwgYUwCQYDVR0TBAIwADALBgNVHQ8E
|
24
|
+
BAMCBLAwHQYDVR0OBBYEFGq+yaiaa5fcNGtNzPk64oVEJYUcMCUGA1UdEQQeMByB
|
25
|
+
GmxhdXJlbnQubWFydGluLmxAZ21haWwuY29tMCUGA1UdEgQeMByBGmxhdXJlbnQu
|
26
|
+
bWFydGluLmxAZ21haWwuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBZf2H7OVPcLrvM
|
27
|
+
Xy9CEJ72vs4fxjedp23jbUI+qLMUYPLCAdxe1Mbsbtp3/UcnwZWzcu8YZl+fA+29
|
28
|
+
f/s8LV6MccR3YiPgdnJhYUnNTQR/PuqsaAtyMbznW/+fv0/5IBsBd7Y0AJmO0JQl
|
29
|
+
1yAvw+U0E+nMfQ2Ifbwo0P0N1qmQhMDS8c7TBi8/ODaPfX/US+Ypt5VTgghL5f12
|
30
|
+
j64llzG5eLduyc+OnBevqJ8aYtbgrV3vFEQYL2n+buCV8TvXIEjqGNrUAMnbITCD
|
31
|
+
eT4yyJQaHXUl49wQcaNxTyWN9uqhAiv5EJqm19jJeYoQD/qPKP5jEyQdIFx5R/5A
|
32
|
+
ty9+j+pZ
|
33
|
+
-----END CERTIFICATE-----
|
34
|
+
date: 2024-01-04 00:00:00.000000000 Z
|
35
|
+
dependencies:
|
36
|
+
- !ruby/object:Gem::Dependency
|
37
|
+
name: rubyzip
|
38
|
+
requirement: !ruby/object:Gem::Requirement
|
39
|
+
requirements:
|
40
|
+
- - "~>"
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: '2.0'
|
43
|
+
type: :runtime
|
44
|
+
prerelease: false
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
46
|
+
requirements:
|
47
|
+
- - "~>"
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
version: '2.0'
|
50
|
+
- !ruby/object:Gem::Dependency
|
51
|
+
name: xml-simple
|
52
|
+
requirement: !ruby/object:Gem::Requirement
|
53
|
+
requirements:
|
54
|
+
- - "~>"
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: '1.0'
|
57
|
+
type: :runtime
|
58
|
+
prerelease: false
|
59
|
+
version_requirements: !ruby/object:Gem::Requirement
|
60
|
+
requirements:
|
61
|
+
- - "~>"
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: '1.0'
|
64
|
+
- !ruby/object:Gem::Dependency
|
65
|
+
name: bundler
|
66
|
+
requirement: !ruby/object:Gem::Requirement
|
67
|
+
requirements:
|
68
|
+
- - "~>"
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: '2.0'
|
71
|
+
type: :development
|
72
|
+
prerelease: false
|
73
|
+
version_requirements: !ruby/object:Gem::Requirement
|
74
|
+
requirements:
|
75
|
+
- - "~>"
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '2.0'
|
78
|
+
- !ruby/object:Gem::Dependency
|
79
|
+
name: rake
|
80
|
+
requirement: !ruby/object:Gem::Requirement
|
81
|
+
requirements:
|
82
|
+
- - "~>"
|
83
|
+
- !ruby/object:Gem::Version
|
84
|
+
version: '13.0'
|
85
|
+
type: :development
|
86
|
+
prerelease: false
|
87
|
+
version_requirements: !ruby/object:Gem::Requirement
|
88
|
+
requirements:
|
89
|
+
- - "~>"
|
90
|
+
- !ruby/object:Gem::Version
|
91
|
+
version: '13.0'
|
92
|
+
- !ruby/object:Gem::Dependency
|
93
|
+
name: reek
|
94
|
+
requirement: !ruby/object:Gem::Requirement
|
95
|
+
requirements:
|
96
|
+
- - "~>"
|
97
|
+
- !ruby/object:Gem::Version
|
98
|
+
version: 6.1.0
|
99
|
+
type: :development
|
100
|
+
prerelease: false
|
101
|
+
version_requirements: !ruby/object:Gem::Requirement
|
102
|
+
requirements:
|
103
|
+
- - "~>"
|
104
|
+
- !ruby/object:Gem::Version
|
105
|
+
version: 6.1.0
|
106
|
+
- !ruby/object:Gem::Dependency
|
107
|
+
name: rspec
|
108
|
+
requirement: !ruby/object:Gem::Requirement
|
109
|
+
requirements:
|
110
|
+
- - "~>"
|
111
|
+
- !ruby/object:Gem::Version
|
112
|
+
version: '3.0'
|
113
|
+
type: :development
|
114
|
+
prerelease: false
|
115
|
+
version_requirements: !ruby/object:Gem::Requirement
|
116
|
+
requirements:
|
117
|
+
- - "~>"
|
118
|
+
- !ruby/object:Gem::Version
|
119
|
+
version: '3.0'
|
120
|
+
- !ruby/object:Gem::Dependency
|
121
|
+
name: rubocop
|
122
|
+
requirement: !ruby/object:Gem::Requirement
|
123
|
+
requirements:
|
124
|
+
- - "~>"
|
125
|
+
- !ruby/object:Gem::Version
|
126
|
+
version: '1.12'
|
127
|
+
type: :development
|
128
|
+
prerelease: false
|
129
|
+
version_requirements: !ruby/object:Gem::Requirement
|
130
|
+
requirements:
|
131
|
+
- - "~>"
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
version: '1.12'
|
134
|
+
- !ruby/object:Gem::Dependency
|
135
|
+
name: rubocop-ast
|
136
|
+
requirement: !ruby/object:Gem::Requirement
|
137
|
+
requirements:
|
138
|
+
- - "~>"
|
139
|
+
- !ruby/object:Gem::Version
|
140
|
+
version: '1.4'
|
141
|
+
type: :development
|
142
|
+
prerelease: false
|
143
|
+
version_requirements: !ruby/object:Gem::Requirement
|
144
|
+
requirements:
|
145
|
+
- - "~>"
|
146
|
+
- !ruby/object:Gem::Version
|
147
|
+
version: '1.4'
|
148
|
+
- !ruby/object:Gem::Dependency
|
149
|
+
name: rubocop-performance
|
150
|
+
requirement: !ruby/object:Gem::Requirement
|
151
|
+
requirements:
|
152
|
+
- - "~>"
|
153
|
+
- !ruby/object:Gem::Version
|
154
|
+
version: '1.10'
|
155
|
+
type: :development
|
156
|
+
prerelease: false
|
157
|
+
version_requirements: !ruby/object:Gem::Requirement
|
158
|
+
requirements:
|
159
|
+
- - "~>"
|
160
|
+
- !ruby/object:Gem::Version
|
161
|
+
version: '1.10'
|
162
|
+
- !ruby/object:Gem::Dependency
|
163
|
+
name: rubocop-shopify
|
164
|
+
requirement: !ruby/object:Gem::Requirement
|
165
|
+
requirements:
|
166
|
+
- - "~>"
|
167
|
+
- !ruby/object:Gem::Version
|
168
|
+
version: '2.0'
|
169
|
+
type: :development
|
170
|
+
prerelease: false
|
171
|
+
version_requirements: !ruby/object:Gem::Requirement
|
172
|
+
requirements:
|
173
|
+
- - "~>"
|
174
|
+
- !ruby/object:Gem::Version
|
175
|
+
version: '2.0'
|
176
|
+
- !ruby/object:Gem::Dependency
|
177
|
+
name: simplecov
|
178
|
+
requirement: !ruby/object:Gem::Requirement
|
179
|
+
requirements:
|
180
|
+
- - "~>"
|
181
|
+
- !ruby/object:Gem::Version
|
182
|
+
version: '0.18'
|
183
|
+
type: :development
|
184
|
+
prerelease: false
|
185
|
+
version_requirements: !ruby/object:Gem::Requirement
|
186
|
+
requirements:
|
187
|
+
- - "~>"
|
188
|
+
- !ruby/object:Gem::Version
|
189
|
+
version: '0.18'
|
190
|
+
- !ruby/object:Gem::Dependency
|
191
|
+
name: solargraph
|
192
|
+
requirement: !ruby/object:Gem::Requirement
|
193
|
+
requirements:
|
194
|
+
- - "~>"
|
195
|
+
- !ruby/object:Gem::Version
|
196
|
+
version: '0.44'
|
197
|
+
type: :development
|
198
|
+
prerelease: false
|
199
|
+
version_requirements: !ruby/object:Gem::Requirement
|
200
|
+
requirements:
|
201
|
+
- - "~>"
|
202
|
+
- !ruby/object:Gem::Version
|
203
|
+
version: '0.44'
|
204
|
+
description: Generate Home Assistant configuration from ETS project
|
205
|
+
email:
|
206
|
+
- laurent.martin.l@gmail.com
|
207
|
+
executables:
|
208
|
+
- ets_to_hass
|
209
|
+
extensions: []
|
210
|
+
extra_rdoc_files: []
|
211
|
+
files:
|
212
|
+
- CHANGELOG.md
|
213
|
+
- README.md
|
214
|
+
- bin/ets_to_hass
|
215
|
+
- lib/ets_to_hass/generator.rb
|
216
|
+
- lib/ets_to_hass/info.rb
|
217
|
+
- lib/ets_to_hass/specific/generic.rb
|
218
|
+
- lib/ets_to_hass/specific/laurent.rb
|
219
|
+
- lib/ets_to_hass/specific/specific2.rb
|
220
|
+
- lib/ets_to_hass/string_colors.rb
|
221
|
+
homepage: https://github.com/laurent-martin/ets-to-homeassistant
|
222
|
+
licenses:
|
223
|
+
- Apache-2.0
|
224
|
+
metadata:
|
225
|
+
allowed_push_host: https://rubygems.org
|
226
|
+
homepage_uri: https://github.com/laurent-martin/ets-to-homeassistant
|
227
|
+
source_code_uri: https://github.com/laurent-martin/ets-to-homeassistant
|
228
|
+
changelog_uri: https://github.com/laurent-martin/ets-to-homeassistant
|
229
|
+
rubygems_uri: https://github.com/laurent-martin/ets-to-homeassistant
|
230
|
+
documentation_uri: https://github.com/laurent-martin/ets-to-homeassistant
|
231
|
+
bug_tracker_uri: https://github.com/laurent-martin/ets-to-homeassistant
|
232
|
+
rubygems_mfa_required: 'true'
|
233
|
+
post_install_message:
|
234
|
+
rdoc_options: []
|
235
|
+
require_paths:
|
236
|
+
- lib
|
237
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
238
|
+
requirements:
|
239
|
+
- - ">="
|
240
|
+
- !ruby/object:Gem::Version
|
241
|
+
version: '2.7'
|
242
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
243
|
+
requirements:
|
244
|
+
- - ">="
|
245
|
+
- !ruby/object:Gem::Version
|
246
|
+
version: '0'
|
247
|
+
requirements:
|
248
|
+
- Read the manual for any requirement
|
249
|
+
rubygems_version: 3.3.26
|
250
|
+
signing_key:
|
251
|
+
specification_version: 4
|
252
|
+
summary: Tool to generate Home Assistant configuration from ETS project
|
253
|
+
test_files: []
|
metadata.gz.sig
ADDED