Initial implementation of modes, though they're not quite working.
This commit is contained in:
parent
15b9fa6ac0
commit
cc37904fad
7 changed files with 116 additions and 67 deletions
|
@ -34,6 +34,7 @@ func initVirtualBuffers(config *config.ConfigParser) map[string]*virtualdevice.E
|
||||||
return vBuffers
|
return vBuffers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extracts the evdev devices from a list of virtual buffers and returns them.
|
||||||
func getVirtualDevices(buffers map[string]*virtualdevice.EventBuffer) map[string]*evdev.InputDevice {
|
func getVirtualDevices(buffers map[string]*virtualdevice.EventBuffer) map[string]*evdev.InputDevice {
|
||||||
devices := make(map[string]*evdev.InputDevice)
|
devices := make(map[string]*evdev.InputDevice)
|
||||||
for name, buffer := range buffers {
|
for name, buffer := range buffers {
|
||||||
|
@ -79,6 +80,9 @@ func mapEvents(vBuffers map[string]*virtualdevice.EventBuffer, pDevices map[stri
|
||||||
go eventWatcher(device, eventChannel)
|
go eventWatcher(device, eventChannel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initialize the mode variable
|
||||||
|
mode := "main"
|
||||||
|
|
||||||
fmt.Println("Joyful Running! Press Ctrl+C to quit.")
|
fmt.Println("Joyful Running! Press Ctrl+C to quit.")
|
||||||
for {
|
for {
|
||||||
// Get an event (blocks if necessary)
|
// Get an event (blocks if necessary)
|
||||||
|
@ -95,7 +99,7 @@ func mapEvents(vBuffers map[string]*virtualdevice.EventBuffer, pDevices map[stri
|
||||||
case evdev.EV_ABS:
|
case evdev.EV_ABS:
|
||||||
// We have a matchable event type. Check all the events
|
// We have a matchable event type. Check all the events
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
outputEvent := rule.MatchEvent(wrapper.Device, wrapper.Event)
|
outputEvent := rule.MatchEvent(wrapper.Device, wrapper.Event, &mode)
|
||||||
if outputEvent == nil {
|
if outputEvent == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,10 +3,10 @@
|
||||||
// Example usage:
|
// Example usage:
|
||||||
// config := &config.ConfigParser{}
|
// config := &config.ConfigParser{}
|
||||||
// config.Parse(<some directory containing YAML files>)
|
// config.Parse(<some directory containing YAML files>)
|
||||||
// virtualDevices, err := config.CreateVirtualDevices()
|
// virtualDevices := config.CreateVirtualDevices()
|
||||||
// physicalDevices, err := config.ConnectVirtualDevices()
|
// physicalDevices := config.ConnectVirtualDevices()
|
||||||
// modes, err := config.GetModes()
|
// modes := config.GetModes()
|
||||||
// rules, err := config.BuildRules(physicalDevices, virtualDevices, modes)
|
// rules := config.BuildRules(physicalDevices, virtualDevices, modes)
|
||||||
//
|
//
|
||||||
// nb: there are methods defined on ConfigParser in other files in this package!
|
// nb: there are methods defined on ConfigParser in other files in this package!
|
||||||
|
|
||||||
|
@ -58,7 +58,7 @@ func (parser *ConfigParser) Parse(directory string) error {
|
||||||
logger.LogIfError(err, "Error parsing YAML")
|
logger.LogIfError(err, "Error parsing YAML")
|
||||||
parser.config.Rules = append(parser.config.Rules, newConfig.Rules...)
|
parser.config.Rules = append(parser.config.Rules, newConfig.Rules...)
|
||||||
parser.config.Devices = append(parser.config.Devices, newConfig.Devices...)
|
parser.config.Devices = append(parser.config.Devices, newConfig.Devices...)
|
||||||
// parser.config.Groups = append(parser.config.Groups, newConfig.Groups...)
|
parser.config.Modes = append(parser.config.Modes, newConfig.Modes...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,3 +68,7 @@ func (parser *ConfigParser) Parse(directory string) error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (parser *ConfigParser) getModes() []string {
|
||||||
|
return append([]string{"main"}, parser.config.Modes...)
|
||||||
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.annabunches.net/annabunches/joyful/internal/logger"
|
"git.annabunches.net/annabunches/joyful/internal/logger"
|
||||||
|
@ -12,54 +13,71 @@ import (
|
||||||
// TODO: At some point it would *very likely* make sense to map each rule to all of the physical devices that can
|
// TODO: At some point it would *very likely* make sense to map each rule to all of the physical devices that can
|
||||||
// trigger it, and return that instead. Something like a map[*evdev.InputDevice][]mappingrule.MappingRule.
|
// trigger it, and return that instead. Something like a map[*evdev.InputDevice][]mappingrule.MappingRule.
|
||||||
// This would speed up rule matching by only checking relevant rules for a given input event.
|
// This would speed up rule matching by only checking relevant rules for a given input event.
|
||||||
// We could take this further and make it a map[*evdev.InputDevice]map[evdev.InputType]map[evdev.InputCode][]mappingrule.MappingRule
|
// We could take this further and make it a map[<struct of *inputdevice, type, and code>][]rule
|
||||||
// For very large rule-bases this may be helpful for staying performant.
|
// For very large rule-bases this may be helpful for staying performant.
|
||||||
func (parser *ConfigParser) BuildRules(pDevs map[string]*evdev.InputDevice, vDevs map[string]*evdev.InputDevice) []mappingrules.MappingRule {
|
func (parser *ConfigParser) BuildRules(pDevs map[string]*evdev.InputDevice, vDevs map[string]*evdev.InputDevice) []mappingrules.MappingRule {
|
||||||
rules := make([]mappingrules.MappingRule, 0)
|
rules := make([]mappingrules.MappingRule, 0)
|
||||||
|
modes := parser.getModes()
|
||||||
|
|
||||||
for _, ruleConfig := range parser.config.Rules {
|
for _, ruleConfig := range parser.config.Rules {
|
||||||
var newRule mappingrules.MappingRule
|
var newRule mappingrules.MappingRule
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
baseParams, err := setBaseRuleParameters(ruleConfig, vDevs, modes)
|
||||||
|
if err != nil {
|
||||||
|
logger.LogError(err, "couldn't set output parameters, skipping rule")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logf("DEBUG: Modes for rule '%s': %v", baseParams.Name, baseParams.Modes)
|
||||||
|
|
||||||
switch strings.ToLower(ruleConfig.Type) {
|
switch strings.ToLower(ruleConfig.Type) {
|
||||||
case RuleTypeSimple:
|
case RuleTypeSimple:
|
||||||
newRule, err = makeSimpleRule(ruleConfig, pDevs, vDevs)
|
newRule, err = makeSimpleRule(ruleConfig, pDevs, baseParams)
|
||||||
case RuleTypeCombo:
|
case RuleTypeCombo:
|
||||||
newRule, err = makeComboRule(ruleConfig, pDevs, vDevs)
|
newRule, err = makeComboRule(ruleConfig, pDevs, baseParams)
|
||||||
case RuleTypeLatched:
|
case RuleTypeLatched:
|
||||||
newRule, err = makeLatchedRule(ruleConfig, pDevs, vDevs)
|
newRule, err = makeLatchedRule(ruleConfig, pDevs, baseParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.LogError(err, "")
|
logger.LogError(err, "")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
rules = append(rules, newRule)
|
rules = append(rules, newRule)
|
||||||
}
|
}
|
||||||
|
|
||||||
return rules
|
return rules
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeSimpleRule(ruleConfig RuleConfig, pDevs map[string]*evdev.InputDevice, vDevs map[string]*evdev.InputDevice) (*mappingrules.SimpleMappingRule, error) {
|
func setBaseRuleParameters(ruleConfig RuleConfig, vDevs map[string]*evdev.InputDevice, modes []string) (mappingrules.MappingRuleBase, error) {
|
||||||
|
output, err := makeRuleTarget(ruleConfig.Output, vDevs)
|
||||||
|
if err != nil {
|
||||||
|
return mappingrules.MappingRuleBase{}, err
|
||||||
|
}
|
||||||
|
ruleModes := verifyModes(ruleConfig, modes)
|
||||||
|
|
||||||
|
return mappingrules.MappingRuleBase{
|
||||||
|
Output: output,
|
||||||
|
Modes: ruleModes,
|
||||||
|
Name: ruleConfig.Name,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeSimpleRule(ruleConfig RuleConfig, pDevs map[string]*evdev.InputDevice, base mappingrules.MappingRuleBase) (*mappingrules.SimpleMappingRule, error) {
|
||||||
input, err := makeRuleTarget(ruleConfig.Input, pDevs)
|
input, err := makeRuleTarget(ruleConfig.Input, pDevs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
output, err := makeRuleTarget(ruleConfig.Output, vDevs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &mappingrules.SimpleMappingRule{
|
return &mappingrules.SimpleMappingRule{
|
||||||
MappingRuleBase: mappingrules.MappingRuleBase{
|
MappingRuleBase: base,
|
||||||
Output: output,
|
|
||||||
},
|
|
||||||
Input: input,
|
Input: input,
|
||||||
Name: ruleConfig.Name,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeComboRule(ruleConfig RuleConfig, pDevs map[string]*evdev.InputDevice, vDevs map[string]*evdev.InputDevice) (*mappingrules.ComboMappingRule, error) {
|
func makeComboRule(ruleConfig RuleConfig, pDevs map[string]*evdev.InputDevice, base mappingrules.MappingRuleBase) (*mappingrules.ComboMappingRule, error) {
|
||||||
inputs := make([]mappingrules.RuleTarget, 0)
|
inputs := make([]mappingrules.RuleTarget, 0)
|
||||||
for _, inputConfig := range ruleConfig.Inputs {
|
for _, inputConfig := range ruleConfig.Inputs {
|
||||||
input, err := makeRuleTarget(inputConfig, pDevs)
|
input, err := makeRuleTarget(inputConfig, pDevs)
|
||||||
|
@ -69,38 +87,22 @@ func makeComboRule(ruleConfig RuleConfig, pDevs map[string]*evdev.InputDevice, v
|
||||||
inputs = append(inputs, input)
|
inputs = append(inputs, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
output, err := makeRuleTarget(ruleConfig.Output, vDevs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &mappingrules.ComboMappingRule{
|
return &mappingrules.ComboMappingRule{
|
||||||
MappingRuleBase: mappingrules.MappingRuleBase{
|
MappingRuleBase: base,
|
||||||
Output: output,
|
|
||||||
},
|
|
||||||
Inputs: inputs,
|
Inputs: inputs,
|
||||||
State: 0,
|
State: 0,
|
||||||
Name: ruleConfig.Name,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeLatchedRule(ruleConfig RuleConfig, pDevs map[string]*evdev.InputDevice, vDevs map[string]*evdev.InputDevice) (*mappingrules.LatchedMappingRule, error) {
|
func makeLatchedRule(ruleConfig RuleConfig, pDevs map[string]*evdev.InputDevice, base mappingrules.MappingRuleBase) (*mappingrules.LatchedMappingRule, error) {
|
||||||
input, err := makeRuleTarget(ruleConfig.Input, pDevs)
|
input, err := makeRuleTarget(ruleConfig.Input, pDevs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
output, err := makeRuleTarget(ruleConfig.Output, vDevs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &mappingrules.LatchedMappingRule{
|
return &mappingrules.LatchedMappingRule{
|
||||||
MappingRuleBase: mappingrules.MappingRuleBase{
|
MappingRuleBase: base,
|
||||||
Output: output,
|
|
||||||
},
|
|
||||||
Input: input,
|
Input: input,
|
||||||
Name: ruleConfig.Name,
|
|
||||||
State: false,
|
State: false,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
@ -152,3 +154,21 @@ func decodeRuleTargetValues(target RuleTargetConfig) (evdev.EvType, evdev.EvCode
|
||||||
|
|
||||||
return eventType, eventCode, nil
|
return eventType, eventCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func verifyModes(ruleConfig RuleConfig, modes []string) []string {
|
||||||
|
verifiedModes := make([]string, 0)
|
||||||
|
|
||||||
|
for _, configMode := range ruleConfig.Modes {
|
||||||
|
if !slices.Contains(modes, configMode) {
|
||||||
|
logger.Logf("rule '%s' specifies undefined mode '%s', skipping", ruleConfig.Name, configMode)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
verifiedModes = append(verifiedModes, configMode)
|
||||||
|
}
|
||||||
|
if len(verifiedModes) == 0 {
|
||||||
|
verifiedModes = []string{"main"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifiedModes
|
||||||
|
}
|
||||||
|
|
|
@ -5,8 +5,7 @@ package config
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Devices []DeviceConfig `yaml:"devices"`
|
Devices []DeviceConfig `yaml:"devices"`
|
||||||
// TODO: add groups
|
Modes []string `yaml:"modes,omitempty"`
|
||||||
// Groups []GroupConfig `yaml:"groups,omitempty"`
|
|
||||||
Rules []RuleConfig `yaml:"rules"`
|
Rules []RuleConfig `yaml:"rules"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,6 +24,7 @@ type RuleConfig struct {
|
||||||
Input RuleTargetConfig `yaml:"input,omitempty"`
|
Input RuleTargetConfig `yaml:"input,omitempty"`
|
||||||
Inputs []RuleTargetConfig `yaml:"inputs,omitempty"`
|
Inputs []RuleTargetConfig `yaml:"inputs,omitempty"`
|
||||||
Output RuleTargetConfig `yaml:"output"`
|
Output RuleTargetConfig `yaml:"output"`
|
||||||
|
Modes []string `yaml:"modes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuleTargetConfig struct {
|
type RuleTargetConfig struct {
|
||||||
|
@ -32,5 +32,4 @@ type RuleTargetConfig struct {
|
||||||
Button string `yaml:"button,omitempty"`
|
Button string `yaml:"button,omitempty"`
|
||||||
Axis string `yaml:"axis,omitempty"`
|
Axis string `yaml:"axis,omitempty"`
|
||||||
Inverted bool `yaml:"inverted,omitempty"`
|
Inverted bool `yaml:"inverted,omitempty"`
|
||||||
Groups []string `yaml:"groups,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package mappingrules
|
package mappingrules
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
|
|
||||||
"git.annabunches.net/annabunches/joyful/internal/logger"
|
"git.annabunches.net/annabunches/joyful/internal/logger"
|
||||||
"github.com/holoplot/go-evdev"
|
"github.com/holoplot/go-evdev"
|
||||||
)
|
)
|
||||||
|
@ -9,6 +11,10 @@ func (rule *MappingRuleBase) OutputName() string {
|
||||||
return rule.Output.DeviceName
|
return rule.Output.DeviceName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rule *MappingRuleBase) modeCheck(mode *string) bool {
|
||||||
|
return slices.Contains(rule.Modes, *mode)
|
||||||
|
}
|
||||||
|
|
||||||
// eventFromTarget creates an outputtable event from a RuleTarget
|
// eventFromTarget creates an outputtable event from a RuleTarget
|
||||||
func eventFromTarget(output RuleTarget, value int32) *evdev.InputEvent {
|
func eventFromTarget(output RuleTarget, value int32) *evdev.InputEvent {
|
||||||
return &evdev.InputEvent{
|
return &evdev.InputEvent{
|
||||||
|
@ -40,7 +46,11 @@ func valueFromTarget(rule RuleTarget, event *evdev.InputEvent) int32 {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rule *SimpleMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent) *evdev.InputEvent {
|
func (rule *SimpleMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) *evdev.InputEvent {
|
||||||
|
if !rule.MappingRuleBase.modeCheck(mode) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if device != rule.Input.Device ||
|
if device != rule.Input.Device ||
|
||||||
event.Code != rule.Input.Code {
|
event.Code != rule.Input.Code {
|
||||||
return nil
|
return nil
|
||||||
|
@ -49,7 +59,11 @@ func (rule *SimpleMappingRule) MatchEvent(device *evdev.InputDevice, event *evde
|
||||||
return eventFromTarget(rule.Output, valueFromTarget(rule.Input, event))
|
return eventFromTarget(rule.Output, valueFromTarget(rule.Input, event))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rule *ComboMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent) *evdev.InputEvent {
|
func (rule *ComboMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) *evdev.InputEvent {
|
||||||
|
if !rule.MappingRuleBase.modeCheck(mode) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Check each of the inputs, and if we find a match, proceed
|
// Check each of the inputs, and if we find a match, proceed
|
||||||
var match *RuleTarget
|
var match *RuleTarget
|
||||||
for _, input := range rule.Inputs {
|
for _, input := range rule.Inputs {
|
||||||
|
@ -83,7 +97,11 @@ func (rule *ComboMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rule *LatchedMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent) *evdev.InputEvent {
|
func (rule *LatchedMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) *evdev.InputEvent {
|
||||||
|
if !rule.MappingRuleBase.modeCheck(mode) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if device != rule.Input.Device ||
|
if device != rule.Input.Device ||
|
||||||
event.Code != rule.Input.Code ||
|
event.Code != rule.Input.Code ||
|
||||||
valueFromTarget(rule.Input, event) == 0 {
|
valueFromTarget(rule.Input, event) == 0 {
|
||||||
|
|
|
@ -3,33 +3,32 @@ package mappingrules
|
||||||
import "github.com/holoplot/go-evdev"
|
import "github.com/holoplot/go-evdev"
|
||||||
|
|
||||||
type MappingRule interface {
|
type MappingRule interface {
|
||||||
MatchEvent(*evdev.InputDevice, *evdev.InputEvent) *evdev.InputEvent
|
MatchEvent(*evdev.InputDevice, *evdev.InputEvent, *string) *evdev.InputEvent
|
||||||
OutputName() string
|
OutputName() string
|
||||||
}
|
}
|
||||||
|
|
||||||
type MappingRuleBase struct {
|
type MappingRuleBase struct {
|
||||||
|
Name string
|
||||||
Output RuleTarget
|
Output RuleTarget
|
||||||
|
Modes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// A Simple Mapping Rule can map a button to a button or an axis to an axis.
|
// A Simple Mapping Rule can map a button to a button or an axis to an axis.
|
||||||
type SimpleMappingRule struct {
|
type SimpleMappingRule struct {
|
||||||
MappingRuleBase
|
MappingRuleBase
|
||||||
Input RuleTarget
|
Input RuleTarget
|
||||||
Name string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A Combo Mapping Rule can require multiple physical button presses for a single output button
|
// A Combo Mapping Rule can require multiple physical button presses for a single output button
|
||||||
type ComboMappingRule struct {
|
type ComboMappingRule struct {
|
||||||
MappingRuleBase
|
MappingRuleBase
|
||||||
Inputs []RuleTarget
|
Inputs []RuleTarget
|
||||||
Name string
|
|
||||||
State int
|
State int
|
||||||
}
|
}
|
||||||
|
|
||||||
type LatchedMappingRule struct {
|
type LatchedMappingRule struct {
|
||||||
MappingRuleBase
|
MappingRuleBase
|
||||||
Input RuleTarget
|
Input RuleTarget
|
||||||
Name string
|
|
||||||
State bool
|
State bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,7 @@ Joyful might be the tool for you.
|
||||||
|
|
||||||
* Multiple modes with per-mode behavior.
|
* Multiple modes with per-mode behavior.
|
||||||
* Partial axis mapping: map sections of an axis to different outputs.
|
* Partial axis mapping: map sections of an axis to different outputs.
|
||||||
|
* Highly configurable deadzones
|
||||||
* Macros - have a single input produce a sequence of button presses with configurable pauses.
|
* Macros - have a single input produce a sequence of button presses with configurable pauses.
|
||||||
* Sequence combos - Button1, Button2, Button3 -> VirtualButtonA
|
* Sequence combos - Button1, Button2, Button3 -> VirtualButtonA
|
||||||
|
|
||||||
|
@ -62,6 +63,10 @@ All `rules` must have a `type` field. Valid values for this field are:
|
||||||
|
|
||||||
Configuration options for each type vary. See <examples/ruletypes.yml> for an example of each type with all options specified.
|
Configuration options for each type vary. See <examples/ruletypes.yml> for an example of each type with all options specified.
|
||||||
|
|
||||||
|
### Modes
|
||||||
|
|
||||||
|
All rules can have a `modes` field that is a list of strings.
|
||||||
|
|
||||||
|
|
||||||
## Technical details
|
## Technical details
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue