Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 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
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>
183 changes: 183 additions & 0 deletions util/service/modbus.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
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/fatih/structs"
"github.com/spf13/cast"
)

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

var (
log = util.NewLogger("modbus")
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 == "") || cc["address"] == nil {
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 {
log.TRACE.Printf("failed to read register %d from %s: %v", query.Address, cacheKey, err)
jsonError(w, http.StatusInternalServerError, err)
return
}

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

log.TRACE.Printf("read register %d from %s: %v", query.Address, cacheKey, value)

// 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) {
// Convert Settings to map (plugin expects Settings fields at top level)
cfg := structs.Map(query.Settings)

// Plugin expects Register as nested object, not flattened
cfg["register"] = query.Register
cfg["scale"] = query.Scale

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" {
g, err := p.(plugin.StringGetter).StringGetter()
if err != nil {
return nil, err
}
return g()
}

// For all numeric encodings (int*, float*, bool*), use FloatGetter
g, err := p.(plugin.FloatGetter).FloatGetter()
if err != nil {
return nil, err
}
return g()
}

// 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 "bool":
return cast.ToBool(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