Skip navigation

After a Twitter convo about weather stations I picked up a WeatherFlow Tempest. Setup was quick, but the sensor package died within 24 hours. I was going to give up on it but I had written an R package (for the REST API & UDP broadcast interfaces) and C++ utility (for just the UDP broadcast interface), and the support staff were both friendly and competent and sent me a replacement super quick.

I’ve blathered about the R package already (on Twitter) so am not going to tag that here, but will link to a few repositories (in various languages) that receive the UDP broadcast messages and at least shove them to stdout.

The C++ one is mostly C but gets the job done (it just posts the messages to stdout). It should run everywhere but I only tested on macOS & Linux, because Windows is a terrible operating system nobody should use.

The Golang one has some structured types to consume about half of the JSON messages (I’ve only seen four in the broadcasts so far, and will add more as I see new ones). It’s only more verbose than the C++ one due to the various record type handling. This should run everywhere, though.

For kicks, I threw together a Swift one that is really just Swift-ified C and is a Frankenstein monster that likely shouldn’t be used. (I’ll be making a SwiftUI macOS/iOS/iPadOS app for the UDP broadcast messages, though, soon).

To round out my obsession I also made a Rust version which I’m just in 💙 with (not because of any skill of my own). It’s the smallest source file and is pretty elegant (100% due to Rust, and, again, not me).

All the code/projects are super small, but the Rust source is so tiny that it won’t be too intrusive to post here:

use std::net::UdpSocket;

fn main() -> std::io::Result<()> {

  let mut buf = [0; 1024]; // 1024 byte buffer is plenty
  let s = UdpSocket::bind("0.0.0.0:50222").expect(r#"{"message":"Could not bind to address/port."}"#);

  loop {

    let (n, _) = s.recv_from(&mut buf).expect(r#"{"message":"No broadcasts received."}"#);

    println!("{}", String::from_utf8(buf[..n].to_vec()).unwrap())

  }

}

FIN

If you’re interested in a low-cost weather station with great DIY programming support, I’d definitely (so far, at least) recommend the Tempest. We’ll see if it survives the forthcoming snowpocalypse.

These are the JSON messages it slings over UDP:

{"serial_number":"HB-00069665","type":"hub_status","firmware_revision":"177","uptime":728643,"rssi":-50,"timestamp":1643246011,"reset_flags":"BOR,PIN,POR","seq":72787,"radio_stats":[25,1,0,3,16637],"mqtt_stats":[10,108]}
{"serial_number":"ST-00055227","type":"rapid_wind","hub_sn":"HB-00069665","ob":[1643246013,0.00,0]}
{"serial_number":"ST-00055227","type":"rapid_wind","hub_sn":"HB-00069665","ob":[1643246015,0.00,0]}
{"serial_number":"ST-00055227","type":"device_status","hub_sn":"HB-00069665","timestamp":1643246016,"uptime":106625,"voltage":2.683,"firmware_revision":165,"rssi":-72,"hub_rssi":-66,"sensor_status":655364,"debug":0}
{"serial_number":"ST-00055227","type":"obs_st","hub_sn":"HB-00069665","obs":[[1643246016,0.00,0.00,0.00,0,3,1024.56,-12.82,47.84,0,0.00,0,0.000000,0,0,0,2.683,1]],"firmware_revision":165}

The New York Times had a [tragic] story on Covid deaths today and one of their plots really stuck with me for how well it told that part of the story.

NYT Chart on Covid Deaths

NOTE: The red panel highlights are off a bit as I manually typed the data in (I only did the recreation to keep {ggplot2} muscle memory as I hadn’t doe a major customization like this in quite some time).

Only one {grid} hack (for the faceted X axis labels) too!

Hopefully, I’ll have more real-world opportunity to build some detailed, properly-annotated {ggplot2} plots this year.

Shout out to @ClausWilke for {ggtext} and all the folks who’ve made {ggplot2} such a powerful data visualization tool.

library(grid)
library(gtable)
library(hrbrthemes)
library(tidyverse)

gtable_filter_remove <- function (x, name, trim = FALSE) {
  # https://stackoverflow.com/a/36780639
  matches <- !(x$layout$name %in% name)
  x$layout <- x$layout[matches, , drop = FALSE]
  x$grobs <- x$grobs[matches]
  if (trim) 
    x <- gtable_trim(x)
  x
}

read.csv(text="race,age_group,before,after,cause
White,Under 25,1,3,Covid-19 deaths increased as a share of deaths from all cause
White,25-44,3,10,Covid-19 deaths increased as a share of deaths from all cause
White,45-64,8,15,Covid-19 deaths increased as a share of deaths from all cause
White,65-84,13,11,NA
White,85+,14,6,NA
Hispanic,Under 25,3,4,Covid-19 deaths increased as a share of deaths from all cause
Hispanic,25-44,17,21,Covid-19 deaths increased as a share of deaths from all cause
Hispanic,45-64,33,26,NA
Hispanic,65-84,33,17,NA
Hispanic,85+,21,9,NA
Black,Under 25,1,3,Covid-19 deaths increased as a share of deaths from all cause
Black,25-44,7,13,Covid-19 deaths increased as a share of deaths from all cause
Black,45-64,15,17,Covid-19 deaths increased as a share of deaths from all cause
Black,65-84,20,12,Covid-19 deaths increased as a share of deaths from all cause
Black,85+,17,8,NA
Asian,Under 25,2,4,Covid-19 deaths increased as a share of deaths from all cause
Asian,25-44,12,14,Covid-19 deaths increased as a share of deaths from all cause
Asian,45-64,21,13,NA
Asian,65-84,23,8,NA
Asian,85+,17,4,NA") -> xdf

xdf %>% 
  mutate(
    before = before/100,
    after = after/100,
    age_group = fct_inorder(age_group),
    race = factor(race, levels = rev(c("Asian", "Black", "Hispanic", "White")))
  ) -> xdf

{

  ggplot( data = xdf) +
    geom_rect(
      data = xdf,
      aes(
        xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf,
        fill = cause
      ),
      alpha = 1/6, color = NA
    ) +
    geom_rect(
      data = xdf %>% 
        filter(
          (race == "White" & age_group %in% c("65-84", "85+")) |
            (race == "Hispanic" & age_group %in% c("45-64", "65-84", "85+")) |
            (race == "Black" & age_group %in% c("85+")) |
            (race == "Asian" & age_group %in% c("45-64", "65-84", "85+"))
        ),
      aes(
        xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf
      ),
      fill = "#999999", alpha = 1/6, color = NA
    ) +
    geom_segment(
      aes(-Inf, xend = Inf, -Inf, yend= -Inf),
      size = 0.25, color = "black"
    ) +
    geom_segment(
      data = xdf, aes("1", before, xend="2", yend=after),
      size = 0.25
    ) +
    geom_point(
      data = xdf, aes("1", before), 
      fill = "#999999", color = "white", size = 2, stroke = 0.5, shape = 21
    ) +
    geom_point(
      data = xdf, aes("2", after),
      fill = "#bb271a", color = "white", size = 2, stroke = 0.5, shape = 21
    ) +
    geom_text(
      data = xdf,
      aes("1", before+0.05, label = scales::percent(before, 1)),
      color = "#999999", family = font_es_bold, fontface = "bold", size = 3
    ) +
    geom_text(
      data = xdf,
      aes("2", after+0.05, label = scales::percent(after, 1)),
      color = "#bb271a", family = font_es_bold, fontface = "bold", size = 3
    ) +
    scale_x_discrete(
      expand = c(0, 0),
      labels = c("<span style='color:#999999'>BEFORE</span>", "<span style='color:#bb271a'>AFTER</a>")
    ) +
    scale_y_percent(
      limits = c(-0.005, 0.405),
      breaks = c(-0.005, 0.1, 0.2, 0.3, 0.405),
      labels = c("", "", "", "", "40%\nof deaths from\nall causes for\nthis group")
    ) +
    scale_fill_manual(
      name = NULL,
      values = c("#bb271a"),
      na.translate = FALSE
    ) +
    coord_cartesian(clip = "off") +
    facet_wrap(
      facets = race~age_group, 
      scales = "free_x",
      labeller = \(labels, multi_line = TRUE){
        labels <- lapply(labels, as.character)
        labels[["race"]][c(1,2,4,5,6,7,9,10,11,12,14,15,16,17,19,20)] <- ""
        labels[["age_group"]] <- sprintf("<span style='font-style:normal;font-weight:normal;'>%s</span>", labels[["age_group"]])
        labels[["race"]][c(3,8,13,18)] <- sprintf("<span style='font-size:12pt;'>**%s**</span>", labels[["race"]][c(3,8,13,18)])
        labels
      }
    ) +
    labs(
      x = NULL, y = NULL,
      title = "Covid-19 deaths <span style='color:#999999'>before</span> and <span style='color:#bb271a'>after</span> universal adult vaccine eligibility",
      caption = "Source: Provisional weekly death data from the C.D.C. through Nov. 27. Note: Only the four largest racial and ethnic groups are included. Universal vaccine eligibility was April 19, the date when all adults in the United States were eligible for vaccination."
    ) +
    theme_ipsum_es(grid="Y", plot_title_size = 16) +
    theme(
      plot.title.position = "plot",
      plot.title = ggtext::element_markdown(hjust = 0.5),
      plot.caption = ggtext::element_textbox_simple(
        hjust = 0, size = 8.5, family = font_es, color = "#999999",
        margin = margin(t = 14)
      ),
      axis.ticks.x.bottom = ell(size = 0.25) ,
      axis.line.x.bottom = ell(lineend = "square", size = 0.25),
      axis.text.x.bottom = ggtext::element_markdown(size = 8, margin = margin(t = 6)),
      axis.text.y.left = elt(size = 8, vjust = 1, lineheight = 0.875,  color = "#999999"),
      strip.text.x = ggtext::element_markdown(hjust = 0.5, size = 10, family = font_es),
      strip.text = ggtext::element_markdown(hjust = 0.5, size = 10, family = font_es),
      panel.spacing.x = unit("40", "pt"),
      panel.spacing.y = unit(6, "pt"),
      panel.border = elb(),
      legend.position = "top"
    ) -> gg

  grid.newpage()
  grid.draw(
    gtable_filter_remove(
      x = ggplotGrob(gg),
      name = c(sprintf("axis-b-%d-1", 2:5), sprintf("axis-b-%d-2", 2:5), sprintf("axis-b-%d-3", 2:5), sprintf("axis-b-%d-4", 2:5))
    )
  )

}

{ggplot2} recreation of NYT plot

The Moderna booster level drained me all day on Dec 1 and did what jab two did during the overnight period (achy enough to wake me up and not get back to slumber easily). To try to wear myself down, I decided to practice a bit of R with the 2021 Advent of Code. There are plenty of superb R bloggers chronicling their daily katas that I do not feel compelled to post every one (truth be told, work and fam tasks/priorities will make devoting any time to this year’s daily puzzles a rare event).

Day 01 was very straightforward (even part 2 which I used {RcppRoll} despite hoping to stick to only base R 4.x) so it’s kinda not worth a post (for me), but Day 02 was kinda fun as I don’t have regular opportunities to use scan() and get().

The input is a series of submarine commands:

forward 5
down 5
forward 8
up 3
down 8
forward 2

with a set of rules that change between parts 1 and 2.

We can read in those commands with scan() which lets us specify a pattern for each line (scan() takes care of dealing with whitespace for you):

scan(
  text = "forward 5
down 5
forward 8
up 3
down 8
forward 2",
what = list(character(0), integer(0))
) |>
  setNames(c("command", "value")) -> input

str(input)
## List of 2
##  $ command: chr [1:6] "forward" "down" "forward" "up" ...
##  $ value  : int [1:6] 5 5 8 3 8 2

The rules (link above) were pretty basic, increment/decrement some variables based on the command input, but I wanted to avoid a bunch of if statements. Since R has the get() function that enables searching by name for an object, we can make a series of functions that have the command as the identifier and then use get() to call the function:

up <- \(x) depth <<- depth - x
down <- \(x) depth <<- depth + x
forward <- \(x) position <<- position + x

position <- depth <- 0

for (idx in seq_along(input$command)) {
  get(input$command[idx], mode = "function")(input$value[idx])
}

(the final answer is computed by position X depth).

While I find this to be a “fun” solution, I’d strongly suggest:

  • avoiding using the new shortcut function declaration in mixed R version shops as it’s very new and likely to be confusing to new R users
  • being wary of the <<- assignment operator as it’s introducing a side-effect (parent/global environment modification) which will come back to bite you in other projects some day
  • ditching the $ referencing in favour of [[]] / [] to avoid partial name matching “gotchas”, and
  • adding explicit documentation to what you’re doing with get() calls (provided you really have a good case for using get() to begin with)

The code only changes slightly for part 2, so I’ll refrain from adding even more unreadable code from this post.

I’ve been wanting to create a custom MaxMind mmdb file for alternate IPv4 range classifications for a while, and finally had an opportunity to do so for the $DAYJOB. MaxMind mmdb files are small and easy to work with and provide lightning fast lookups.

This repo — https://github.com/hrbrmstr/clouds2mmdb — has an R script for pulling cloud provider info (I already had some boilerplate code for it and detest reinventing the wheel in another language just to keep a project in a single language) and a Python script for the custom mmdb writing.

Current providers supported are:

  • AWS
  • Azure
  • DigitalOcean
  • Google (GCP)
  • Oracle Cloud
  • Rackspace (see clouds2csv.r for the details)

and, PRs are welcome for other cloud providers.

The provider info is stored in the isp field:

mmdblookup --file clouds.mmdb  --ip 167.99.224.0 isp 
## 
##   "DigitalOcean" <utf8_string>
##

The 30-Day Map Challenge is on again, and I’m hoping to be able to scrounge some time to get an entry for each day. Day 2 is lines (Day 1 was posted on Twitter only) and — while I’m hoping to focus on saving U.S. democracy for the majority of the entries, today’s is a short one that shows all the walks/hikes/boats we took during our Iceland vacation.

I use HealthFit to export data (automagically) from all my Apple Watch, Garmin, and Peloton activities, and have it auto-sync to iCloud, which means I have instant access to all the Garmin FIT files it creates in ~/Library/Mobile Documents/iCloud~com~altifondo~HealthFit/.

We can use {FITFileR} to read in those files, turn the points into lines, and plot them all on a {leaflet} map, so I can get an interactive view into the paths.

If I had more time, I’d add activity names and other clickable statistics in popups, which is pretty straightforward in {leaflet}.

Comments in code hopefully explain the workflow:

library(sf)
library(FITfileR) # remotes::install_github("grimbough/FITfileR")
library(leaflet)
library(tidyverse)

# get a listing of all the files for when we were in Iceland
list.files(
  path = "~/Library/Mobile Documents/iCloud~com~altifondo~HealthFit/Documents", 
  pattern = "(2021-07-3[01]|2021-08-0[1-9])",
  full.names = TRUE
) %>% 
  map(
    ~readFitFile(.x) %>%          # read the file in
      records() %>%               # turn into something we can then bind into a data frame
      bind_rows() %>%             # bind ^^ into a data frame!
      mutate(file = basename(.x)) # add the file info in case we want to eventually make a popup of information
  ) -> iceland_fit_files


# look at the data

iceland_fit_files[[1]]
## # A tibble: 2,210 × 12
##    timestamp           position_lat position_long gps_accuracy altitude distance speed temperature heart_rate cadence
##    <dttm>                     <dbl>         <dbl>        <dbl>    <dbl>    <dbl> <dbl>       <dbl>      <dbl>   <dbl>
##  1 2021-07-30 06:28:26         64.2         -21.9            2     21       318. 0.221          13        116      NA
##  2 2021-07-30 06:28:27         64.2         -21.9            2     21       318. 0.521          13        116      NA
##  3 2021-07-30 06:28:28         64.2         -21.9            2     21       319. 0.792          13        116      NA
##  4 2021-07-30 06:28:47         64.2         -21.9            2     19.4     338. 0.217          13        118      NA
##  5 2021-07-30 06:28:48         64.2         -21.9            2     19.2     338. 0.147          13        118      NA
##  6 2021-07-30 06:28:49         64.2         -21.9            2     19       338. 0.109          13        117      NA
##  7 2021-07-30 06:28:50         64.2         -21.9            2     19       338. 0.076          13        117      NA
##  8 2021-07-30 06:28:51         64.2         -21.9            2     18.8     338. 0.036          13        116      NA
##  9 2021-07-30 06:28:52         64.2         -21.9            2     18.6     338. 0.153          13        115      NA
## 10 2021-07-30 06:28:53         64.2         -21.9            2     18.4     339. 0.393          13        115      NA
## # … with 2,200 more rows, and 2 more variables: fractional_cadence <dbl>, file <chr>

iceland_fit_files[map_lgl(iceland_fit_files, has_name, "position_long")] %>% # only want activities with geo data
  map(
    ~.x %>% 
      filter(!is.na(position_long), !is.na(position_lat)) %>% # {sf} hates NAs
      st_as_sf(
        coords = c("position_long", "position_lat"), # turn the data frame into an {sf} object
        crs = 4326
      )
  ) %>% 
  bind_rows() %>% # this makes one big data frame
  group_by(file) %>%  # which we can turn into individual geometries
  summarise(          # with the epic summarise() function
    m = max(distance)
  ) %>% 
  st_cast("LINESTRING") -> paths # and then turn the points into linestrings

# let's take a look

paths
## Simple feature collection with 33 features and 2 fields
## Geometry type: LINESTRING
## Dimension:     XY
## Bounding box:  xmin: -23.92788 ymin: 63.40121 xmax: -16.18086 ymax: 65.08101
## Geodetic CRS:  WGS 84
## # A tibble: 33 × 3
##    file                                        m                                                                    geometry
##    <chr>                                   <dbl>                                                            <LINESTRING [°]>
##  1 2021-07-30-062401-Walking-Chetzmoka.fit 2807. (-21.94386 64.15495, -21.94382 64.15497, -21.94384 64.15496, -21.94388 64.…
##  2 2021-07-30-101146-Walking-Chetzmoka.fit 1191. (-21.93504 64.14761, -21.93502 64.1476, -21.93503 64.14761, -21.93501 64.1…
##  3 2021-07-30-105706-Walking-Chetzmoka.fit 1554. (-21.91706 64.12906, -21.91704 64.12905, -21.91702 64.12905, -21.917 64.12…
##  4 2021-07-30-143247-Walking-Chetzmoka.fit 1620. (-21.94977 64.15683, -21.94978 64.15685, -21.94979 64.15686, -21.94995 64.…
##  5 2021-07-31-122122-Walking-Chetzmoka.fit  702. (-22.26431 64.7628, -22.26463 64.76297, -22.26527 64.76332, -22.26496 64.7…
##  6 2021-07-31-131832-Walking-Chetzmoka.fit 2084. (-22.84353 64.90553, -22.84351 64.90553, -22.84349 64.90553, -22.84347 64.…
##  7 2021-07-31-182725-Walking-Chetzmoka.fit  578. (-22.72256 65.0809, -22.72255 65.0809, -22.72254 65.08089, -22.72252 65.08…
##  8 2021-08-01-105420-Walking-Chetzmoka.fit 1210. (-23.63732 64.79827, -23.6373 64.79829, -23.6373 64.79829, -23.6373 64.798…
##  9 2021-08-01-142847-Walking-Chetzmoka.fit 2385. (-23.80382 64.73048, -23.8038 64.73047, -23.8038 64.73047, -23.8038 64.730…
## 10 2021-08-01-165642-Walking-Chetzmoka.fit  423. (-23.92745 64.85198, -23.92749 64.85197, -23.92747 64.85197, -23.92746 64.…
## # … with 23 more rows

# make room locally

dir.create("~/projects/2021-iceland", showWarnings = FALSE)

# save the widget out

paths %>% 
  filter(!grepl("2021-08-10", file)) %>% 
  leaflet() %>% 
  addProviderTiles("CartoDB.Positron") %>% 
  addPolygons(color = "green") %>% 
  htmlwidgets::saveWidget(
    file = "~/projects/2021-iceland/index.html",
    selfcontained = FALSE
  )

# rsync it out to my website
#

This is the (static) overview:

And, this is a zoom into our boating tour of a glacier lagoon:

Hit up the widget to see where we did our Iceland activities this summer!

RStudio is continuing to refine their 👍🏽 new product version string scheme, with the most recent change using something like the following for daily builds:

RStudio-pro-2021.11.0-daily+23.pro1.dmg

The full URL for these Amazon S3-hosted builds is something like:

https://s3.amazonaws.com/rstudio-ide-build/desktop/macos/RStudio-pro-2021.11.0-daily%2B23.pro1.dmg

with the + getting URL encoded to %2B.

Unfortunately, AWS S3’s fairly unintelligent web server really wanted that + encoded to %2B for it to properly map to the retrieved-by-explicit-path-key bucket-backed object store. Since it was not, the download component of RSwitch v2.x failed with a somewhat inaccurate error message.

It was a minor fix that I finally had some spare cycles to make today.

You can download the RSwitch 2.1.2b universal binary here.

Vox grabbed some data from the Kaiser Family Foundation and did a story a few days ago on it, then posted a different visualization of it that attracted some attention:

I’m a pretty ardent donut detractor, but I have to also admit that they work pretty well for this use case, and we can reproduce the graphic in R quite nicely. The following code chunk:

  • scrapes the datawrapper chart Vox embedded in their original article
  • extracts the data from it using V8
  • reformats it and uses some basic ggplot2 idioms for making the facets
library(V8) # we need to execute javascript to get the data
library(ggtext) # pretty ggtext
library(stringi) # some basic string ops
library(rvest) # scraping!
library(hrbragg) # remotes::install_github("hrbrmstr/hrbragg")
library(tidyverse) # duh

ctx <- v8() # init the V8 engine

pg <- read_html("https://datawrapper.dwcdn.net/jlEL9/6/") # get the data — this is embedded in the Vox article

# clean up the javascript so we can evaluate it
html_nodes(pg, xpath = ".//script[contains(., 'DW_SVEL')]") %>% 
  html_text() %>% 
  stri_replace_first_fixed("window.", "") %>% 
  stri_replace_all_regex("window.__.*", "", multiline = TRUE) %>% 
  ctx$eval()

# get the dat from the V8 engine and reshape it a bit, then join it with state abbreviations
ctx$get("__DW_SVELTE_PROPS__")$data %>% 
  read_tsv(
    col_names = c(
      "state", 
      "Reported cases among fully vaccinated", 
      "Reported cases among not fully vaccinated"
    )
  ) %>% 
  gather(measure, value, -state) %>% 
  left_join(
    tibble(
      state = state.name,
      abbr = state.abb
    ) %>% 
      add_row(
        state = "District of Columbia",
        abbr = "DC"
      ) 
  ) -> voxxed

# basic ggplot idiom for faceted donuts

ggplot() +
  geom_col(
    data = voxxed,
    aes(3, value, fill = measure), # play with "3" here and below to change width of the donut
    color = NA,
    position = position_stack()
  ) +
  ggtext::geom_richtext(
    data = voxxed %>% filter(measure == "Reported cases among not fully vaccinated"),
    # change 0.2 and 0 to see what they do
    aes(0.2, 0, label = sprintf("%s<br/><b><span style='color:#8a264a'>%s%%</span></b>", abbr, value)),
    size = 5, label.size = 0
  ) +
  scale_x_continuous(
    limits = c(0.2, 3 + 0.5) # this 3 links to the 3 above. tweak 0.2 and 0.5 to see what it does to the shape
  ) +
  scale_fill_manual(
    name = NULL,
    values = c(
      "Reported cases among fully vaccinated" = "#d59e67",
      "Reported cases among not fully vaccinated" = "#8a264a"
    )
  ) +
  coord_polar(theta = "y") +
  facet_wrap(~state, ncol = 6) +
  labs(
    x = NULL, y = NULL,
    title = "Breakthrough cases are not driving the US Covid-19 surge",
    caption = "Source: Kaiser Family Foundation\nNote: Case data in recent months, as of July\nOriginal chart by Vox <https://www.vox.com/22602039/breakthrough-cases-covid-19-delta-variant-masks-vaccines>"
  ) +
  theme_inter(grid="") +
  theme(
    axis.text.x = elb(),
    axis.text.x.top = elb(),
    axis.text.x.bottom = elb(),
    axis.text.y = elb(),
    axis.text.y.left = elb(),
    axis.text.y.right = elb()
  ) +
  theme(
    panel.spacing.x = unit(0, "npc"),
    panel.spacing.y = unit(0, "npc")
  ) + 
  theme(
    strip.text = elb(),
    strip.text.x = elb(),
    strip.text.y = elb()
  ) +
  theme(
    legend.position = "top",
    legend.text = elt(size = 12),
    legend.justification = "left"
  ) +
  theme(
    plot.caption = elt(hjust = 0)
  )

Version 2 of RSwitch — the macOS menubar utility that enables fast and seamless switching between R versions on macOS and also provides notifications for new versions of RStudio Dailies has had a hasty update to deal with an error condition if/when the RStudio Dailies pages contain no links (they’re switching versioning schemes and I happened to catch this right at that time). The original RSwitch v1.x should be fine as long as the non-RStudio Pro dailies page isn’t busted.

Version 2.1.1b🔗 also adds a downloader for the arm64 version of the R-devel tarball:

The preferences also has a new button that lets you clear the cached previously seen latest daily download versions if you’re not getting notified (there could be a hiccup depending on when the last check was and the state of the daily download pages).

prefs

FIN

As noted, this is a hasty fix (right before heading out on holiday), so if there are issues it’ll be a bit before they get fixed.