Invert service discovery dependencies

WIP fix for #7107.
The goal is to make all SD implementations depend on github.com/prometheus/prometheus/discovery instead of discovery depending on all SD implementations. My intention was to do it all at once, but I can do it implementation by implementation if necessary.

WIP fix for #7107.
The plan is to make all SD implementations depend on github.com/prometheus/prometheus/discovery instead of discovery depending on all of the implementations. My intention was to do it all at once, but I can probably do it implementation by implementation if necessary.

For each SD we will need to carefully review things, e.g. the path of the files. I'd rather work by moving 2-3 implementations in one pr.
The last discoveries we want to move are probably: consul, k8s, file.

There should be enough there now to see where it's going... I tried to separate things out into the smallest chunks per commit... The high-level outline of what I've done so far is something like:

shuffle things around to prevent cyclic dependencies
add the discoverer.Config interface
add the Marshaling/Unmarshaling logic and use it with static_configs
remove the dependency on openstack
remove the dependency on azure

Note that the commits that remove a particular dependency are very small.
Future steps would be something like:

remove the dependencies on all other discovery implementations
remove ServiceDiscoveryConfig and replace its uses with []discoverer.Config
shuffle things around again since cycles are gone (package discoverer gets merged into package discovery)

This is really going to the right direction, :+1:

The error message on unknown field is now obscure:
level=error ts=2020-08-02T12:27:48.142Z caller=main.go:287 msg="Error loading config (--config.file=prometheus.yml)" err="parsing YAML file
prometheus.yml: yaml: unmarshal errors:\n  line 15: field lazure_sd_configs not found in type struct { JobName string "yaml:\"job_name
\""; HonorLabels bool "yaml:\"honor_labels,omitempty\""; HonorTimestamps bool "yaml:\"honor_timestamps\""; Params url.Values
"yaml:\"params,omitempty\""; ScrapeInterval model.Duration "yaml:\"scrape_interval,omitempty\""; ScrapeTimeout model.Duration
"yaml:\"scrape_timeout,omitempty\""; MetricsPath string "yaml:\"metrics_path,omitempty\""; Scheme string "yaml:\"scheme,omite
mpty\""; SampleLimit uint "yaml:\"sample_limit,omitempty\""; TargetLimit uint "yaml:\"target_limit,omitempty\""; ServiceDisc
overyConfig discoverer.ServiceDiscoveryConfig "yaml:\",inline\""; HTTPClientConfig config.HTTPClientConfig "yaml:\",inline\"";
RelabelConfigs []*relabel.Config "yaml:\"relabel_configs,omitempty\""; MetricRelabelConfigs []*relabel.Config "yaml:\"metric_relab
el_configs,omitempty\""; AUTO_azure_sd_configs []*azure.SDConfig "yaml:\"azure_sd_configs,omitempty\""; AUTO_openstack_sd_configs
[]*openstack.SDConfig "yaml:\"openstack_sd_configs,omitempty\""; AUTO_static_configs []*config.staticConfig "yaml:\"static_configs
,omitempty\"" }"

I've resolved everything so far except the obscure error message. I assume that's gonna require some really gross and fragile error string matching and manipulating, but I haven't focused on it yet.
I'm probably gonna hit a few more dependencies before going back to the error message.

This is very close to complete now... What I've done so far:

shuffle things around to prevent cyclic dependencies
add the discoverer.Config interface
add the Marshaling/Unmarshaling logic and use it with static_configs
remove each dependency one at a time
remove ServiceDiscoveryConfig and replace its uses with []discoverer.Config
shuffle things around again since cycles are gone, moving types in pkg discoverer back into pkg discovery

There are still a few TODOs:

fix the obscure error message
move a few config functions from pkg discoverer to common module
remove pkg discoverer

This is done now and ready for review. I don't plan to make any more changes unless they are requested.
As I said before, I tried to limit each commit to a small logical chunk but I realize that overall this is a very large change.
The sequence of commits should be mergable at any step. Let me know if you need me to split it up.

I do actually have another long sequence of cleanup commits ready, but they can wait until after this :)

Thanks a lot for this.

It was fun :)

Could you add some docs in discovery/README.md?

Done.

Are we testing the marshalling of the config?

We were a little bit (kinda just making sure that it didn't return an error), but I just added yaml roundtrip testing.

@bwplotka, this should make you happy:
goda graph -short github.com/prometheus/prometheus/cmd/prometheus - github.com/prometheus/prometheus/discovery/install | dot -Tsvg -o graph-install.svg

I haven't looked too in depth, but the factoring doesn't seem right here. The special discovery stuff should live as much as is possible in the discovery module, and config only have just enough to use it - discovery should not depend on config.

Please read the existing discussion starting here: https://github.com/prometheus/prometheus/issues/7107#issuecomment-667573474
TL;DR: The yaml package doesn't support the combination of (a) using a yaml.Unmarshaler to decode into dynamically named fields, (b) using yaml inlining to embed that dynamic yaml.Unmarshaler into other types (e.g. ScrapeConfig and AlertmanagerConfig), and (c) using strict decoding mode. There are several options. This seems like the best available without a breaking change.

A long term aspiration is that other software can re-use our discovery code, so things shouldn't be too tightly bound.

I don't think there's anything about builtin discovery implementations telling the config package how to (un)marshal them as YAML in its other configs that prevents people from using them. The problem is the config package pulls in every single builtin discovery implementation, which was prohibitive and is what this is fixing.
If it'll make you feel better, I can move the register calls to the install package so the others won't need to import config.

If it'll make you feel better, I can move the register calls to the install package so the others won't need to import config.

Can those interfaces be part of discovery/ and config import discovery? What would block that?

Can those interfaces be part of discovery and config import discovery? What would block that?

Yeah, config does import discovery and discovery contains all the discovery-related interfaces. The purpose of registering each implementation is only for YAML (un)marshaling of the ScrapeConfig and AlertmanagerConfig types in which they are inlined.
This registration could be moved elsewhere (e.g. discovery), but then it would have to export some magical yaml (un)marshaling functions. I can do that.

FYI others are already importing us: https://github.com/grafana/loki/blob/b24a1430434770a5c4eddc19bfe8e1eddf498a15/pkg/promtail/scrapeconfig/scrapeconfig.go

FYI others are already importing us: https://github.com/grafana/loki/blob/b24a1430434770a5c4eddc19bfe8e1eddf498a15/pkg/promtail/scrapeconfig/scrapeconfig.go

Thanks for the heads up. I'll move the registration to discovery and export the yaml functions needed for inlining.

cc @slim-bean @cyriltovena and @rfratto FYI

Fixed:

registration, reflection, and yaml stuff is moved to the discovery package
Validate() error is optional and promtool will use it if it exists
typos/nits

I'm gonna wait for more concrete feedback before changing anything else.

https://github.com/prometheus/prometheus/issues/5222
We have here a request for users NOT to check for files during unmarshal.

Test are failing.
If you import the new common (v0.12.0) please disable http2 in the client config.

Test are failing.

The go tests are passing, but DCO and CI are failing because I rebased in an unsigned commit labeled [DO NOT MERGE] that vendors the changes in https://github.com/prometheus/common/pull/250, which isn't merged yet.

This dependency is still here. If you want to test this, do so without adding a 2nd way to do struct comparisons to the codebase.

As previously stated, it's needed to test marshaling via roundtrip-ing which obfuscates Secrets, in which case "<secret>" should be a sufficient match for "foobar". Otherwise we're gonna have to write something brittle to replace all secrets, rely on subtle reflection, or not be able to reuse the existing unmarshaling test cases to test marshaling.
Thankfully github.com/google/go-cmp/cmp is specifically designed for testing and conveniently allows you to override equality comparisons for any type you wish. Cool, drop in a cmp.Option to customize the comparison of secrets:
cmp.Comparer(func(x, y config.Secret) bool {
	// Secrets are marshaled as "<secret>" and can't be compared to the original.
	const secret = "<secret>"
	return x == y || (x == secret && y != "") || (y == secret && x != "")
})

However, I first introduced cmp when I was prototyping the new config unmarshaling before writing the marshal roundtrip-ing tests. Previously each discovery.Config type had its own slice but now they are all gathered into a single slice in a particular order (foreshadowing).
At some point I ran the existing tests with the existing comparison method using reflect.DeepEqual, spew.ConfigState.Sdump, and difflib.GetUnifiedDiffString, which yielded this incomprehensible output:
--- FAIL: TestLoadConfig (0.01s)
    /.../prometheus/config/config_test.go:736:

        exp: &config.Config{GlobalConfig:config.GlobalConfig{ScrapeInterval:15000000000, ScrapeTimeout:10000000000, EvaluationInterval:30000000000, QueryLogFile:"", ExternalLabels:labels.Labels{labels.Label{Name:"foo", Value:"bar"}, labels.Label{Name:"monitor", Value:"codelab"}}}, AlertingConfig:config.AlertingConfig{AlertRelabelConfigs:[]*relabel.Config(nil), AlertmanagerConfigs:config.AlertmanagerConfigs{(*config.AlertmanagerConfig)(0x31866c0)}}, RuleFiles:[]string{"testdata/first.rules", "testdata/my/*.rules"}, ScrapeConfigs:[]*config.ScrapeConfig{(*config.ScrapeConfig)(0x3187440), (*config.ScrapeConfig)(0x3187560), (*config.ScrapeConfig)(0x3187680), (*config.ScrapeConfig)(0x31877a0), (*config.ScrapeConfig)(0x31878c0), (*config.ScrapeConfig)(0x31879e0), (*config.ScrapeConfig)(0x3187b00), (*config.ScrapeConfig)(0x3187c20), (*config.ScrapeConfig)(0x3187d40), (*config.ScrapeConfig)(0x3187e60), (*config.ScrapeConfig)(0x3187f80), (*config.ScrapeConfig)(0x3186d80), (*config.ScrapeConfig)(0x3186ea0), (*config.ScrapeConfig)(0x3186fc0), (*config.ScrapeConfig)(0x31870e0), (*config.ScrapeConfig)(0x3187200), (*config.ScrapeConfig)(0x3187320)}, RemoteWriteConfigs:[]*config.RemoteWriteConfig{(*config.RemoteWriteConfig)(0x3186880), (*config.RemoteWriteConfig)(0x3186980)}, RemoteReadConfigs:[]*config.RemoteReadConfig{(*config.RemoteReadConfig)(0x3185060), (*config.RemoteReadConfig)(0x3185120)}}

        got: &config.Config{GlobalConfig:config.GlobalConfig{ScrapeInterval:15000000000, ScrapeTimeout:10000000000, EvaluationInterval:30000000000, QueryLogFile:"", ExternalLabels:labels.Labels{labels.Label{Name:"foo", Value:"bar"}, labels.Label{Name:"monitor", Value:"codelab"}}}, AlertingConfig:config.AlertingConfig{AlertRelabelConfigs:[]*relabel.Config(nil), AlertmanagerConfigs:config.AlertmanagerConfigs{(*config.AlertmanagerConfig)(0xc0004581c0)}}, RuleFiles:[]string{"testdata/first.rules", "testdata/my/*.rules"}, ScrapeConfigs:[]*config.ScrapeConfig{(*config.ScrapeConfig)(0xc000868000), (*config.ScrapeConfig)(0xc000868480), (*config.ScrapeConfig)(0xc0008685a0), (*config.ScrapeConfig)(0xc0008687e0), (*config.ScrapeConfig)(0xc000868900), (*config.ScrapeConfig)(0xc000868a20), (*config.ScrapeConfig)(0xc000868b40), (*config.ScrapeConfig)(0xc000868c60), (*config.ScrapeConfig)(0xc000868d80), (*config.ScrapeConfig)(0xc000868ea0), (*config.ScrapeConfig)(0xc000868fc0), (*config.ScrapeConfig)(0xc000869200), (*config.ScrapeConfig)(0xc000869440), (*config.ScrapeConfig)(0xc000869680), (*config.ScrapeConfig)(0xc0008697a0), (*config.ScrapeConfig)(0xc0008698c0), (*config.ScrapeConfig)(0xc0008699e0)}, RemoteWriteConfigs:[]*config.RemoteWriteConfig{(*config.RemoteWriteConfig)(0xc0003acc30), (*config.RemoteWriteConfig)(0xc0003acd20)}, RemoteReadConfigs:[]*config.RemoteReadConfig{(*config.RemoteReadConfig)(0xc000520420), (*config.RemoteReadConfig)(0xc0005204d0)}}

        Diff:

WHAT?
After trying to make sense of that for way too long, I dropped in a cmp.Diff instead:
--- FAIL: TestLoadConfig (0.01s)
    /.../prometheus/config/config_test.go:702: unexpected diff:

          &config.Config{
          	GlobalConfig:   {ScrapeInterval: s"15s", ScrapeTimeout: s"10s", EvaluationInterval: s"30s", ExternalLabels: {{Name: "foo", Value: "bar"}, {Name: "monitor", Value: "codelab"}}},
          	AlertingConfig: {AlertmanagerConfigs: {&{ServiceDiscoveryConfigs: {discovery.StaticConfig{&{Targets: {s`{__address__="<IP_ADDRESS>:9093"}`, s`{__address__="<IP_ADDRESS>:9093"}`, s`{__address__="<IP_ADDRESS>:9093"}`}, Source: "0"}}}, Scheme: "https", Timeout: s"10s", APIVersion: "v1", ...}}},
          	RuleFiles:      {"testdata/first.rules", "testdata/my/*.rules"},
          	ScrapeConfigs: []*config.ScrapeConfig{
          		&{
          			... // 8 identical fields
          			SampleLimit: 0,
          			TargetLimit: 0,
          			ServiceDiscoveryConfigs: discovery.Configs{
        - 				discovery.StaticConfig{s"0"},
          				&file.SDConfig{Files: {"testdata/foo/*.slow.json", "testdata/foo/*.slow.yml", "testdata/single/file.yml"}, RefreshInterval: s"10m"},
          				&file.SDConfig{Files: {"testdata/bar/*.yaml"}, RefreshInterval: s"5m"},
        + 				discovery.StaticConfig{s"0"},
          			},
          			HTTPClientConfig:     {BearerTokenFile: "testdata/valid_token_file"},
          			RelabelConfigs:       {&{SourceLabels: {"job", "__meta_dns_name"}, Separator: ";", Regex: s"^(?:(.*)some-[regex])$", TargetLabel: "job", ...}, &{SourceLabels: {"abc"}, Separator: ";", Regex: s"^(?:(.*))$", TargetLabel: "cde", ...}, &{Separator: ";", Regex: s"^(?:(.*))$", TargetLabel: "abc", Replacement: "static", ...}, &{Separator: ";", Regex: s"^(?:)$", TargetLabel: "abc", Replacement: "static", ...}},
          			MetricRelabelConfigs: nil,
          		},
          		&{JobName: "service-x", HonorTimestamps: true, ScrapeInterval: s"50s", ScrapeTimeout: s"5s", ...},
          		&{JobName: "service-y", HonorTimestamps: true, ScrapeInterval: s"15s", ScrapeTimeout: s"10s", ...},
          		... // 14 identical elements
          	},
          	RemoteWriteConfigs: {&{URL: &{URL: &{Scheme: "http", Host: "remote1", Path: "/push"}}, RemoteTimeout: s"30s", WriteRelabelConfigs: {&{SourceLabels: {"__name__"}, Separator: ";", Regex: s"^(?:expensive.*)$", Replacement: "$1", ...}}, Name: "drop_expensive", ...}, &{URL: &{URL: &{Scheme: "http", Host: "remote2", Path: "/push"}}, RemoteTimeout: s"30s", Name: "rw_tls", HTTPClientConfig: {TLSConfig: {CertFile: "testdata/valid_cert_file", KeyFile: "testdata/valid_key_file"}}, ...}},
          	RemoteReadConfigs:  {&{URL: &{URL: &{Scheme: "http", Host: "remote1", Path: "/read"}}, RemoteTimeout: s"1m", ReadRecent: true, Name: "default", ...}, &{URL: &{URL: &{Scheme: "http", Host: "remote3", Path: "/read"}}, RemoteTimeout: s"1m", Name: "read_special", HTTPClientConfig: {TLSConfig: {CertFile: "testdata/valid_cert_file", KeyFile: "testdata/valid_key_file"}}, ...}},
          }

Ahhhh... The expected value had its manually constructed slice of configs ordered differently. Cool, fix the ordering in the test case and drop in another cmp.Option so nobody is ever bitten by this again:
cmp.Transformer("discovery.Config.Sorter", func(cfgs discovery.Configs) discovery.Configs {
	// The service discovery configs are marshaled and unmarshaled
	// in a particular order, sorted by their names and with
	// static_configs always at the end.
	//
	// At the time of writing this comment, all of the test configs
	// are in this expected order, but adding this tranformer protects
	// future test writers from going mad over inexplicably failed
	// comparisons between unmarshaled yaml configs and hand-crafted
	// go values (with configs in the "wrong" order).
	clone := append(make(discovery.Configs, 0, len(cfgs)), cfgs...)
	sort.SliceStable(clone, func(i, k int) bool {
		return clone[i].Name() < clone[k].Name()
	})
	return clone
})

I believe you're interested in the health and maintainability of the code base, but I don't think removing or crippling these tests is the right decision. That said, I'll do it if that's still your honest assessment.

Sometimes I also switch in my local env our struct comparison thing
because it does not show differences private fields but still errors on
them.

Changing how we do comparisons in tests across all of Prometheus is out of scope I'd say, I'm not sure we've even finished the last round of unittest refactoring.
If you need to do a test that marshalling is working you don't need to go such exhaustive lengths. For example pick one or two cases that cover the new code, and check that the output string matches a provided value. We don't need to consider secrets here, as it's that marshalling is working through the reflection logic is what needs to be tested (plus we've other tests to catch secret issues).

OK. Usage of cmp  and reflect are removed from tests. I just need https://github.com/prometheus/common/pull/250 merged before I can finalize this.

Generally looks fine, a few smaller points.

Issues were all with https://github.com/abursavich/prometheus/commit/d4693a847b0c15988411849f6ae4ff1dec1e3307, which I've dropped.
The config unmarshaling checks "empty or null" values. The custom targetgroup.Group unmarshaling causes {} to be unmarshaled as targetgroup.Group{Targets: model.LabelSet{}} instead of targetgroup.Group{}, because of this line: https://github.com/prometheus/prometheus/blob/25f929b7721c113a827c5cec4ea4c2f502c95dd9/discovery/targetgroup/targetgroup.go#L48
These values are semantically equivalent, but the one with a zero-length non-nil Targets field is not the zero value (i.e. empty) for targetgroup.Group.

Great, thanks for working through this! This is something we've wanted for quite a while.

Thank you @abursavich !!!!

Huzzah! Cheers!

I can only echo @brian-brazil's comment, this has been on our list for a very long time, thank you so much for doing this!

Cortex image went down 33% after applying this change - thanks!
quay.io/cortexproject/cortex    master-e3ca9e391       588cf43d6151        9 days ago          66.7MB
quay.io/cortexproject/cortex    master-2ce5ce6f5       2bea160a1678        10 days ago         101MB

This PR is linked from the 2.21.0 changelog as "Disable HTTP/2 because of concerns with the Go HTTP/2 client."; I cannot easily see where that change is included in this PR and certainly wouldn't guess it from the description.
Please update the description with information about non-obvious changes included in this PR.

This change upgraded github.com/prometheus/common from v0.11.1 to v0.13.0. It required changes that were in v0.13.0. HTTP/2 was disabled in v0.12.0.


JavaScript (Node) Starter Package

An attempt at making a js starter package.
DO NOT MERGE: does not work
the main issues: weird js async/sync things make functions return undefined things
Basically control flow
Any help would be appreciated.

I am going to close this PR until it is near completion.


chore: update studio form type

Issue #, if available:
Description of changes:

updates studio form type

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Codecov Report

Merging #544 (4a11f10) into tagged-release/forms-and-views (69201b8) will not change coverage.
The diff coverage is n/a.

@@                       Coverage Diff                       @@
##           tagged-release/forms-and-views     #544   +/-   ##
===============================================================
  Coverage                           89.02%   89.02%
===============================================================
  Files                                  74       74
  Lines                                2796     2796
  Branches                              777      777
===============================================================
  Hits                                 2489     2489
  Misses                                294      294
  Partials                               13       13

Impacted Files
Coverage Δ

packages/codegen-ui/lib/types/form/index.ts
100.00% <ø> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 69201b8...4a11f10. Read the comment docs.

Only adding id to StudioForm per @hein-j


Non integer mile conversion

When using miles for default units the Range Total and Range by EV fields have a full converted value e.g. 223.68932 miles.  Can this be rounded down to nearest mile to give 223.  So it displays better in homeassistant.

I prefer to keep same experience between KM and Miles. By default, KM shows 1 decimal point as 11,293.7 km
Is this format fine for you?

Yeah that's fine just not multiple decimal places

what values do you see in UVO, miles or KM? because, if conversion is happening through integration, the result of conversion should be INTEGER (bad on me). Check example screenshot below given that UVO shows values in KM and I have picked MILES in integration configuration;

Still subject to the UVO bug where when it loads initially it shows kms but when you refresh it switches to miles in the app. The app preferences are set to miles.

can you dump your E-NIRO Data sensor here after removing sensitive information?

Vehicle data
vehicleLocation:
coord:
lat: 0
lon: 0
alt: 0
type: 0
head: 89
speed:
value: 0
unit: 0
accuracy:
hdop: 10
pdop: 10
time: '20210916220552'
geocodedLocation:
place_id: 111
licence: Data © OpenStreetMap contributors, ODbL 1.0. https://osm.org/copyright
osm_type: way
osm_id: 42741566
lat: ’51’.0
lon: ‘-1.0
display_name: >-
hidden
address:
road:  Crescent
suburb: C
village: North A
county: aa
state_district: South West England
state: England
postcode: XXx xXX
country: United Kingdom
country_code: gb
boundingbox:

'51'
'51'
'-1.7'
'-1.7'
vehicleStatus:
airCtrlOn: false
engine: false
doorLock: true
doorOpen:
frontLeft: 0
frontRight: 0
backLeft: 0
backRight: 0
trunkOpen: false
airTemp:
value: 02H
unit: 0
hvacTempType: 1
defrost: false
acc: false
evStatus:
batteryCharge: false
batteryStatus: 81
batteryPlugin: 0
remainTime2:
etc1:
value: 53
unit: 1
etc2:
value: 430
unit: 1
etc3:
value: 105
unit: 1
atc:
value: 60
unit: 1
drvDistance:
rangeByFuel:
evModeRange:
value: 230.52427
unit: 3
totalAvailableRange:
value: 230.52427
unit: 3
type: 2
reservChargeInfos:
reservChargeInfo:
reservChargeInfoDetail:
reservInfo:
day:
9
time:
time: '1200'
timeSection: 0
reservChargeSet: false
reservFatcSet:
defrost: false
airTemp:
value: 00H
unit: 0
airCtrl: 0
heating1: 0
offpeakPowerInfo:
offPeakPowerTime1:
starttime:
time: '1200'
timeSection: 0
endtime:
time: '1200'
timeSection: 0
offPeakPowerFlag: 0
reserveChargeInfo2:
reservChargeInfoDetail:
reservInfo:
day:
9
time:
time: '1200'
timeSection: 0
reservChargeSet: false
reservFatcSet:
defrost: false
airTemp:
value: 00H
unit: 0
airCtrl: 0
heating1: 0
reservFlag: 0
ect:
start:
day: 9
time:
time: '1200'
timeSection: 0
end:
day: 9
time:
time: '1200'
timeSection: 0
targetSOClist:
targetSOClevel: 100
dte:
rangeByFuel:
evModeRange:
value: 287
unit: 3
totalAvailableRange:
value: 287
unit: 3
type: 2
plugType: 1
targetSOClevel: 100
dte:
rangeByFuel:
evModeRange:
value: 287
unit: 3
totalAvailableRange:
value: 287
unit: 3
type: 2
plugType: 0
ign3: true
hoodOpen: false
transCond: true
steerWheelHeat: 0
sideBackWindowHeat: 0
tirePressureLamp:
tirePressureLampAll: 0
tirePressureLampFL: 0
tirePressureLampFR: 0
tirePressureLampRL: 0
tirePressureLampRR: 0
battery:
batSoc: 76
batState: 0
sleepModeCheck: false
time: '20210916220553'
remoteWaitingTimeAlert:
remoteControlAvailable: 1
remoteControlWaitingTime: 168
elapsedTime: '02:48:21'
systemCutOffAlert: 0
tailLampStatus: 0
hazardStatus: 0
odometer:
value: 397.9
unit: 1
Vehicle name
E-NIRO

As a further development this morning it shows as follows:

And Data shows as this
evModeRange:
value: 366
unit: 1
totalAvailableRange:
value: 366
unit: 1
type: 2
odometer:
value: 401
unit: 1

Then after I do a force update it updates to the following
evModeRange:
value: 227.41748
unit: 3
totalAvailableRange:
value: 227.41748
unit: 3
type: 2
odometer:
value: 401
unit: 1
Vehicle name
So looks like the figures are always km for odometer and correct for range as long as you take the unit field into account

get the latest changes and give it a try

just to be sure, now it had created a bug in your setup which did not exist earlier?

i had simplified overall process but code changes should not impact anything on unit conversion

Not new when I looked the other day it did it too but not always. So was existing intermittent one.

Just loaded latest update and it looks correct at the moment, I'll check it over the next few updates and see anything changes.

rangeByFuel:
evModeRange:
value: 223.06796
unit: 3
totalAvailableRange:
value: 223.06796
unit: 3
type: 2

I am resolving this, let's track conversion issue seperately.