Skip to content
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
b02335b
initial modbus service
iseeberg79 Dec 7, 2025
63ce90a
remove usage filter
iseeberg79 Dec 8, 2025
48466c8
fix ui
iseeberg79 Dec 8, 2025
b8b44ad
fix tests
iseeberg79 Dec 8, 2025
c32a0bc
fix integration, restore
iseeberg79 Dec 8, 2025
0c5ced8
fix, reduce
iseeberg79 Dec 8, 2025
6071b9f
simplify
iseeberg79 Dec 9, 2025
0de806d
cache
iseeberg79 Dec 9, 2025
a648282
refactor
iseeberg79 Dec 9, 2025
884ff34
Merge branch 'master' into feature/service
iseeberg79 Dec 9, 2025
a085515
fix
iseeberg79 Dec 9, 2025
d152738
cleanup
iseeberg79 Dec 9, 2025
35a1ea7
simplify UI
iseeberg79 Dec 9, 2025
0c4a96a
linter
iseeberg79 Dec 9, 2025
e9039d4
remove obsolete
iseeberg79 Dec 9, 2025
2094942
Revert UI simplification
iseeberg79 Dec 9, 2025
d47c424
fix linter
iseeberg79 Dec 9, 2025
2d601b1
mapstructure squash pattern
iseeberg79 Dec 10, 2025
700fdb1
use uri
iseeberg79 Dec 10, 2025
70038f7
remove constants
iseeberg79 Dec 10, 2025
e7bd763
fix test
iseeberg79 Dec 10, 2025
6b00d11
simplify
iseeberg79 Dec 10, 2025
3518ad8
fix
iseeberg79 Dec 10, 2025
1e185c2
dynamic getters
iseeberg79 Dec 10, 2025
6c9ede3
validate
iseeberg79 Dec 10, 2025
75df7fd
simplify
andig Dec 10, 2025
b7bd656
wip
andig Dec 10, 2025
927cda2
fix
iseeberg79 Dec 10, 2025
de1d13e
revert test
iseeberg79 Dec 10, 2025
9119f1a
Delete .project
iseeberg79 Dec 10, 2025
fdf24fc
refactor
iseeberg79 Dec 11, 2025
9871ebf
add serial
iseeberg79 Dec 11, 2025
f4df4a7
lint
iseeberg79 Dec 11, 2025
8e5bf35
applyCast tests
iseeberg79 Dec 11, 2025
3a94c54
logging
iseeberg79 Dec 11, 2025
8d6181b
use mapstructure
iseeberg79 Dec 13, 2025
fabcbc7
simplify pluginGetter
iseeberg79 Dec 13, 2025
86bf70b
wip
iseeberg79 Dec 14, 2025
6573a87
Apply suggestion from @andig
andig Dec 23, 2025
bc4462e
Merge branch 'master' into feature/service
naltatis Dec 23, 2025
6e3336b
feature service dependency groups and yaml defintion
iseeberg79 Dec 23, 2025
7e15216
simplify
iseeberg79 Dec 27, 2025
c1f2c5a
fix linter
iseeberg79 Dec 27, 2025
828332d
simplify, fix
iseeberg79 Dec 27, 2025
632be00
revert changes to types
iseeberg79 Dec 27, 2025
6985b2f
refactor
iseeberg79 Dec 29, 2025
a57b344
Merge branch 'master' into feature/service
iseeberg79 Dec 30, 2025
8dfff46
fix early expand
iseeberg79 Dec 31, 2025
9cfd23b
simplify
iseeberg79 Dec 31, 2025
59a3d98
refactor propertyfield
naltatis Jan 3, 2026
0ced775
Merge branch 'master' into feature/service
naltatis Jan 3, 2026
0dc5f54
fix modbus default handling; add e2e tests
naltatis Jan 3, 2026
27d4dc8
added comment
naltatis Jan 3, 2026
e711ece
cleanup
naltatis Jan 3, 2026
347c9ad
fix test
naltatis Jan 6, 2026
82b5a8b
remove redundant modbus defaults
iseeberg79 Jan 10, 2026
555a065
Merge branch 'master' into feature/service
naltatis Jan 11, 2026
202ee51
Merge branch 'master' into feature/service
naltatis Jan 12, 2026
1072c80
update template docs; add service, auth, missing props
naltatis Jan 12, 2026
cece1cb
rename
naltatis Jan 13, 2026
47b165d
Merge branch 'master' into feature/service
naltatis Jan 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions assets/js/components/Config/DeviceModal/DeviceModalBase.vue
Original file line number Diff line number Diff line change
Expand Up @@ -315,15 +315,6 @@ export default defineComponent({
modbusCapabilities() {
return (this.modbus?.Choice || []) as ModbusCapability[];
},
modbusDefaults() {
const { ID, Comset, Baudrate, Port } = this.modbus || {};
return {
id: ID,
comset: Comset,
baudrate: Baudrate,
port: Port,
};
},
description() {
return this.template?.Requirements?.Description;
},
Expand All @@ -339,7 +330,6 @@ export default defineComponent({
},
apiData(): ApiData {
let data: ApiData = {
...this.modbusDefaults,
...this.values,
};
if (this.values.type === ConfigType.Template && this.templateName) {
Expand Down
17 changes: 17 additions & 0 deletions assets/js/components/Config/DeviceModal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,23 @@ export function applyDefaultsFromTemplate(template: Template | null, values: Dev
.forEach((p) => {
values[p.Name] = p.Default;
});

// Apply modbus defaults from template (for service dependency resolution)
const modbusParam = params.find((p) => p.Name === "modbus") as ModbusParam | undefined;
if (modbusParam) {
if (!values["id"] && modbusParam.ID) {
values["id"] = modbusParam.ID;
}
if (!values["port"] && modbusParam.Port) {
values["port"] = modbusParam.Port;
}
if (!values["comset"] && modbusParam.Comset) {
values["comset"] = modbusParam.Comset;
}
if (!values["baudrate"] && modbusParam.Baudrate) {
values["baudrate"] = modbusParam.Baudrate;
}
}
}

export function customChargerName(type: ConfigType, isHeating: boolean) {
Expand Down
96 changes: 81 additions & 15 deletions assets/js/components/Config/PropertyField.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,43 @@
<template>
<div v-if="unitValue" class="input-group" :class="inputClasses">
<input
:id="id"
v-model="value"
:type="inputType"
:step="step"
:placeholder="placeholder"
:required="required"
:aria-describedby="id + '_unit'"
class="form-control"
:class="{ 'text-end': endAlign }"
/>
<span :id="id + '_unit'" class="input-group-text">{{ unitValue }}</span>
<div v-if="unitValue" :class="sizeClass">
<div class="d-flex">
<div class="position-relative flex-grow-1">
<input
:id="id"
v-model="value"
:list="datalistId"
:type="inputType"
:step="step"
:placeholder="placeholder"
:required="required"
:aria-describedby="id + '_unit'"
:class="`${datalistId && serviceValues.length > 0 ? 'form-select' : 'form-control'} ${showClearButton ? 'has-clear-button' : ''} ${invalid ? 'is-invalid' : ''}`"
class="text-end"
style="border-top-right-radius: 0; border-bottom-right-radius: 0"
:autocomplete="masked || datalistId ? 'off' : null"
/>
<button
v-if="showClearButton"
type="button"
class="form-control-clear"
:aria-label="$t('config.general.clear')"
@click="value = ''"
>
&times;
</button>
<datalist v-if="showDatalist" :id="datalistId">
<option v-for="v in serviceValues" :key="v" :value="v">
{{ v }}
</option>
</datalist>
</div>
<span
:id="id + '_unit'"
class="input-group-text"
style="border-top-left-radius: 0; border-bottom-left-radius: 0"
>{{ unitValue }}</span
>
</div>
</div>
<div v-else-if="icons" class="d-flex flex-wrap">
<div
Expand Down Expand Up @@ -148,7 +174,15 @@ export default {
// no values
if (length === 0) return false;
// value selected, dont offer single same option again
if (this.value && this.serviceValues.includes(this.value)) return false;
// Convert both to strings for comparison to handle number/string type mismatches
const valueStr = String(this.value ?? "");
if (
this.value != null &&
valueStr !== "" &&
this.serviceValues.some((v) => String(v) === valueStr)
) {
return false;
}
return true;
},
showClearButton() {
Expand Down Expand Up @@ -294,7 +328,7 @@ export default {
};
</script>

<style>
<style scoped>
input[type="number"] {
appearance: textfield;
}
Expand All @@ -312,4 +346,36 @@ input[type="number"]::-webkit-inner-spin-button {
.w-min-200 {
min-width: min(200px, 100%);
}

/* Clear button styling */
.form-control-clear {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
z-index: 5;
background: none;
border: none;
color: #6c757d;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0;
width: 1.5rem;
height: 1.5rem;
}

/* Adjust input padding when clear button is visible */
.form-control.has-clear-button {
padding-right: 2rem;
}

/* For form-select with datalist */
.form-select.has-clear-button {
padding-right: 2rem;
}

.form-select.has-clear-button + .form-control-clear {
right: 0.5rem;
}
</style>
189 changes: 189 additions & 0 deletions util/service/modbus.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package service

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"

"github.com/evcc-io/evcc/plugin"
"github.com/evcc-io/evcc/server/service"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/modbus"
"github.com/spf13/cast"
)

// Simple cache for service responses
type cacheEntry struct {
value any
timestamp time.Time
}

var (
cache = make(map[string]cacheEntry)
mu sync.RWMutex
cacheTTL = 1 * time.Minute // Cache for 1 minute
)

// Query combines modbus settings, register config, and additional parameters
type Query struct {
modbus.Settings `mapstructure:",squash"`
modbus.Register `mapstructure:",squash"`
Scale float64 // scaling factor
ResultType string // type cast (int, float, string)
}

func init() {
mux := http.NewServeMux()
mux.HandleFunc("GET /params", getParams)

service.Register("modbus", mux)
}

// getParams reads a parameter value from a device based on URL parameters
// Returns single value as array (for UI compatibility)
func getParams(w http.ResponseWriter, req *http.Request) {
// Convert URL query parameters to map for decoding
cc := make(map[string]any)
for k := range req.URL.Query() {
cc[k] = req.URL.Query().Get(k)
}

// Decode query parameters into Query struct using mapstructure
query := Query{
Scale: 1.0,
}

if err := util.DecodeOther(cc, &query); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}

// Validate required parameters
if (query.URI == "" && query.Device == "") || query.Address == 0 {
jsonError(w, http.StatusBadRequest, fmt.Errorf("uri or device and address parameters are required"))
return
}

// Create cache key from connection string and register address
connStr := query.URI
if connStr == "" {
connStr = query.Device
}
cacheKey := fmt.Sprintf("%s:%d", connStr, query.Address)

// Check cache first
mu.RLock()
if entry, ok := cache[cacheKey]; ok && time.Since(entry.timestamp) < cacheTTL {
mu.RUnlock()
jsonWrite(w, []string{cast.ToString(entry.value)})
return
}
mu.RUnlock()

// Read value from modbus using plugin
// Use background context so connection isn't tied to HTTP request lifecycle
value, err := readRegisterValue(context.TODO(), query)
if err != nil {
jsonWrite(w, []string{}) // Return empty array on error
return
}

// Apply optional cast
if query.ResultType != "" {
value = applyCast(value, query.ResultType)
}

// Store in cache
mu.Lock()
cache[cacheKey] = cacheEntry{
value: value,
timestamp: time.Now(),
}
mu.Unlock()

jsonWrite(w, []string{cast.ToString(value)})
}

// readRegisterValue reads a modbus register value by reusing the modbus plugin
func readRegisterValue(ctx context.Context, query Query) (res any, err error) {
// Build config map for plugin - need to flatten embedded structs manually
cfg := map[string]any{
"uri": query.URI,
"id": query.ID,
"register": query.Register,
"scale": query.Scale,
}

// Add optional settings
if query.Device != "" {
cfg["device"] = query.Device
}
if query.Comset != "" {
cfg["comset"] = query.Comset
}
if query.Baudrate != 0 {
cfg["baudrate"] = query.Baudrate
}

p, err := plugin.NewModbusFromConfig(ctx, cfg)
if err != nil {
return 0, fmt.Errorf("failed to create modbus plugin: %w", err)
}

defer func() {
if r := recover(); r != nil {
res = nil
err = fmt.Errorf("read failed: %v", r)
}
}()

// Choose getter based on encoding type
encoding := strings.ToLower(query.Encoding)

// String encodings need special handling
if encoding == "string" || encoding == "bytes" {
return callGetter(p.(plugin.StringGetter).StringGetter())
}

// For all numeric encodings (int*, uint*, float*, bool*), use FloatGetter
// This is the base implementation in modbus plugin
return callGetter(p.(plugin.FloatGetter).FloatGetter())
}

// callGetter calls a getter function and returns the result
func callGetter[T any](getterFn func() (T, error), err error) (any, error) {
if err != nil {
return nil, err
}
return getterFn()
}

// applyCast applies optional type casting
func applyCast(value any, castType string) any {
switch strings.ToLower(castType) {
case "int":
return cast.ToInt64(value)
case "float":
return cast.ToFloat64(value)
case "string":
return cast.ToString(value)
default:
return value
}
}

// jsonWrite writes a JSON response
func jsonWrite(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}

// jsonError writes an error response
func jsonError(w http.ResponseWriter, status int, err error) {
w.WriteHeader(status)
jsonWrite(w, util.ErrorAsJson(err))
}
Loading
Loading