ets-to-homeassistant 0.1
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 +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
|
+

|
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
|
+

|
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